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

1"""Token mapping configuration loader. 

2 

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""" 

6 

7from __future__ import annotations 

8 

9import json 

10from dataclasses import dataclass 

11from pathlib import Path 

12from typing import Any, Callable, Dict, List, Optional, Tuple 

13 

14from .models import Tokens 

15 

16 

17@dataclass(frozen=True) 

18class CoreMapping: 

19 """A core token-to-CSS-variable mapping.""" 

20 

21 css_var: str 

22 token_path: str 

23 fallback: Optional[str] = None 

24 

25 

26@dataclass(frozen=True) 

27class OptionalGroupConfig: 

28 """Configuration for an optional token group.""" 

29 

30 prefix: str 

31 properties: Tuple[str, ...] = () 

32 mappings: Tuple[CoreMapping, ...] = () 

33 

34 

35@dataclass(frozen=True) 

36class MappingConfig: 

37 """Complete token mapping configuration.""" 

38 

39 prefix: str 

40 core_mappings: Tuple[CoreMapping, ...] 

41 optional_groups: Dict[str, OptionalGroupConfig] 

42 

43 

44def _find_config_path() -> Path: 

45 """Find the token-mappings.json config file. 

46 

47 Searches relative to this module's location, going up to find the config directory. 

48 

49 Returns: 

50 Path to the token-mappings.json file. 

51 

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 

57 

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 ] 

64 

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 

69 

70 raise FileNotFoundError( 

71 "Could not find config/token-mappings.json. " 

72 f"Searched: {[str(p.resolve()) for p in search_paths]}" 

73 ) 

74 

75 

76def _parse_core_mapping(entry: Dict[str, Any]) -> CoreMapping: 

77 """Parse a core mapping entry from JSON. 

78 

79 Args: 

80 entry: Dictionary containing cssVar, tokenPath, and optional fallback. 

81 

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 ) 

90 

91 

92def _parse_optional_group(name: str, data: Dict[str, Any]) -> OptionalGroupConfig: 

93 """Parse an optional group configuration from JSON. 

94 

95 Args: 

96 name: The name of the optional group (e.g., 'spacing', 'elevation'). 

97 data: Dictionary containing prefix, properties, and optional mappings. 

98 

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 ) 

109 

110 

111def load_mapping_config() -> MappingConfig: 

112 """Load the token mapping configuration from JSON. 

113 

114 May raise FileNotFoundError if config file cannot be found, 

115 or json.JSONDecodeError if the config file is invalid JSON. 

116 

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) 

123 

124 core_mappings = tuple( 

125 _parse_core_mapping(entry) for entry in data.get("coreMappings", []) 

126 ) 

127 

128 optional_groups = { 

129 name: _parse_optional_group(name, group_data) 

130 for name, group_data in data.get("optionalGroups", {}).items() 

131 } 

132 

133 return MappingConfig( 

134 prefix=data.get("prefix", "turbo"), 

135 core_mappings=core_mappings, 

136 optional_groups=optional_groups, 

137 ) 

138 

139 

140# Cached config instance 

141_cached_config: Optional[MappingConfig] = None 

142 

143 

144def get_mapping_config() -> MappingConfig: 

145 """Get the cached token mapping configuration. 

146 

147 Loads the config on first access and caches it for subsequent calls. 

148 

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 

156 

157 

158def resolve_token_path(tokens: Tokens, path: str) -> Optional[str]: 

159 """Resolve a dot-separated path to a token value. 

160 

161 Args: 

162 tokens: The Tokens object to traverse. 

163 path: Dot-separated path (e.g., 'background.base'). 

164 

165 Returns: 

166 The resolved string value, or None if the path doesn't exist. 

167 """ 

168 parts = path.split(".") 

169 current: Any = tokens 

170 

171 for part in parts: 

172 if current is None: 

173 return None 

174 

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 

190 

191 return str(current) if current is not None else None 

192 

193 

194def build_token_getter(path: str) -> Callable[[Tokens], Optional[str]]: 

195 """Build a token getter function for a given path. 

196 

197 Args: 

198 path: Dot-separated token path. 

199 

200 Returns: 

201 A callable that takes Tokens and returns the value at the path. 

202 """ 

203 

204 def getter(tokens: Tokens) -> Optional[str]: 

205 return resolve_token_path(tokens, path) 

206 

207 return getter 

208 

209 

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. 

214 

215 This provides backward compatibility with the old inline mapping style. 

216 

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 ]