Coverage for src / turbo_themes / mapping_config.py: 90%
72 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 07:32 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-17 07:32 +0000
1"""Token mapping configuration loader.
3Loads the centralized token-to-CSS-variable mappings from config/token-mappings.json.
4This ensures Python uses the same mappings as TypeScript and other platforms.
5"""
7from __future__ import annotations
9import json
10from dataclasses import dataclass
11from pathlib import Path
12from typing import Any, Callable, Dict, List, Optional, Tuple
14from .models import Tokens
17@dataclass(frozen=True)
18class CoreMapping:
19 """A core token-to-CSS-variable mapping."""
21 css_var: str
22 token_path: str
23 fallback: Optional[str] = None
26@dataclass(frozen=True)
27class OptionalGroupConfig:
28 """Configuration for an optional token group."""
30 prefix: str
31 properties: Tuple[str, ...] = ()
32 mappings: Tuple[CoreMapping, ...] = ()
35@dataclass(frozen=True)
36class MappingConfig:
37 """Complete token mapping configuration."""
39 prefix: str
40 core_mappings: Tuple[CoreMapping, ...]
41 optional_groups: Dict[str, OptionalGroupConfig]
44def _find_config_path() -> Path:
45 """Find the token-mappings.json config file.
47 Searches relative to this module's location, going up to find the config directory.
49 Returns:
50 Path to the token-mappings.json file.
52 Raises:
53 FileNotFoundError: If the config file cannot be found.
54 """
55 # Start from this file's directory and traverse up
56 current = Path(__file__).parent
58 # Try different relative paths
59 search_paths = [
60 current / ".." / ".." / ".." / ".." / "config" / "token-mappings.json",
61 current / ".." / ".." / ".." / "config" / "token-mappings.json",
62 Path.cwd() / "config" / "token-mappings.json",
63 ]
65 for path in search_paths: 65 ↛ 70line 65 didn't jump to line 70 because the loop on line 65 didn't complete
66 resolved = path.resolve()
67 if resolved.exists():
68 return resolved
70 raise FileNotFoundError(
71 "Could not find config/token-mappings.json. "
72 f"Searched: {[str(p.resolve()) for p in search_paths]}"
73 )
76def _parse_core_mapping(entry: Dict[str, Any]) -> CoreMapping:
77 """Parse a core mapping entry from JSON.
79 Args:
80 entry: Dictionary containing cssVar, tokenPath, and optional fallback.
82 Returns:
83 CoreMapping dataclass instance.
84 """
85 return CoreMapping(
86 css_var=entry["cssVar"],
87 token_path=entry["tokenPath"],
88 fallback=entry.get("fallback"),
89 )
92def _parse_optional_group(name: str, data: Dict[str, Any]) -> OptionalGroupConfig:
93 """Parse an optional group configuration from JSON.
95 Args:
96 name: The name of the optional group (e.g., 'spacing', 'elevation').
97 data: Dictionary containing prefix, properties, and optional mappings.
99 Returns:
100 OptionalGroupConfig dataclass instance.
101 """
102 properties = tuple(data.get("properties", []))
103 mappings = tuple(_parse_core_mapping(m) for m in data.get("mappings", []))
104 return OptionalGroupConfig(
105 prefix=data.get("prefix", name),
106 properties=properties,
107 mappings=mappings,
108 )
111def load_mapping_config() -> MappingConfig:
112 """Load the token mapping configuration from JSON.
114 May raise FileNotFoundError if config file cannot be found,
115 or json.JSONDecodeError if the config file is invalid JSON.
117 Returns:
118 Parsed MappingConfig object.
119 """
120 config_path = _find_config_path()
121 with open(config_path, encoding="utf-8") as f:
122 data = json.load(f)
124 core_mappings = tuple(
125 _parse_core_mapping(entry) for entry in data.get("coreMappings", [])
126 )
128 optional_groups = {
129 name: _parse_optional_group(name, group_data)
130 for name, group_data in data.get("optionalGroups", {}).items()
131 }
133 return MappingConfig(
134 prefix=data.get("prefix", "turbo"),
135 core_mappings=core_mappings,
136 optional_groups=optional_groups,
137 )
140# Cached config instance
141_cached_config: Optional[MappingConfig] = None
144def get_mapping_config() -> MappingConfig:
145 """Get the cached token mapping configuration.
147 Loads the config on first access and caches it for subsequent calls.
149 Returns:
150 The cached MappingConfig object.
151 """
152 global _cached_config
153 if _cached_config is None:
154 _cached_config = load_mapping_config()
155 return _cached_config
158def resolve_token_path(tokens: Tokens, path: str) -> Optional[str]:
159 """Resolve a dot-separated path to a token value.
161 Args:
162 tokens: The Tokens object to traverse.
163 path: Dot-separated path (e.g., 'background.base').
165 Returns:
166 The resolved string value, or None if the path doesn't exist.
167 """
168 parts = path.split(".")
169 current: Any = tokens
171 for part in parts:
172 if current is None:
173 return None
175 # Handle attribute access for dataclasses/objects
176 if hasattr(current, part):
177 current = getattr(current, part)
178 # Handle dict-like access
179 elif hasattr(current, "__getitem__"): 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 try:
181 current = current[part]
182 except (KeyError, TypeError):
183 return None
184 # Handle TokenNamespace with to_dict
185 elif hasattr(current, "to_dict"): 185 ↛ 189line 185 didn't jump to line 189 because the condition on line 185 was always true
186 d = current.to_dict()
187 current = d.get(part)
188 else:
189 return None
191 return str(current) if current is not None else None
194def build_token_getter(path: str) -> Callable[[Tokens], Optional[str]]:
195 """Build a token getter function for a given path.
197 Args:
198 path: Dot-separated token path.
200 Returns:
201 A callable that takes Tokens and returns the value at the path.
202 """
204 def getter(tokens: Tokens) -> Optional[str]:
205 return resolve_token_path(tokens, path)
207 return getter
210def get_core_mappings_as_tuples() -> (
211 List[Tuple[str, Callable[[Tokens], Optional[str]]]] # fmt: skip
212):
213 """Get core mappings as (css_suffix, getter) tuples.
215 This provides backward compatibility with the old inline mapping style.
217 Returns:
218 List of (css_var_suffix, getter_function) tuples.
219 """
220 config = get_mapping_config()
221 return [
222 (mapping.css_var, build_token_getter(mapping.token_path))
223 for mapping in config.core_mappings
224 ]