Coverage for src / turbo_themes / models.py: 95%

112 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-17 07:32 +0000

1# SPDX-License-Identifier: MIT 

2"""Type definitions for Turbo Themes. 

3 

4Provides typed access to theme tokens loaded from tokens.json. 

5Replaces the complex quicktype-generated types with a simpler implementation. 

6""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass, field 

11from datetime import datetime 

12from enum import Enum 

13from typing import Any, Dict, Optional 

14 

15 

16class Appearance(Enum): 

17 """Theme appearance (light or dark).""" 

18 

19 LIGHT = "light" 

20 DARK = "dark" 

21 

22 

23class TokenNamespace: 

24 """Dynamic namespace for accessing nested token values. 

25 

26 Converts dict keys to attributes for convenient access like: 

27 tokens.background.base 

28 tokens.text.primary 

29 """ 

30 

31 def __init__(self, data: Dict[str, Any]) -> None: 

32 self._data = data 

33 for key, value in data.items(): 

34 if isinstance(value, dict): 

35 setattr(self, key, TokenNamespace(value)) 

36 else: 

37 setattr(self, key, value) 

38 

39 def __repr__(self) -> str: 

40 return f"TokenNamespace({self._data!r})" 

41 

42 def __getattr__(self, name: str) -> Any: 

43 # Return None for missing attributes instead of raising 

44 return None 

45 

46 def to_dict(self) -> Dict[str, Any]: 

47 """Convert back to dictionary. 

48 

49 Returns: 

50 The underlying dictionary data. 

51 """ 

52 return self._data 

53 

54 

55@dataclass 

56class Tokens: 

57 """Design tokens for a theme. 

58 

59 Provides attribute access to nested token categories: 

60 tokens.background.base 

61 tokens.text.primary 

62 tokens.state.info 

63 """ 

64 

65 _data: Dict[str, Any] = field(repr=False) 

66 

67 # Core token categories (always present) 

68 accent: TokenNamespace = field(init=False) 

69 background: TokenNamespace = field(init=False) 

70 border: TokenNamespace = field(init=False) 

71 brand: TokenNamespace = field(init=False) 

72 content: TokenNamespace = field(init=False) 

73 state: TokenNamespace = field(init=False) 

74 text: TokenNamespace = field(init=False) 

75 typography: TokenNamespace = field(init=False) 

76 

77 # Optional token categories 

78 animation: Optional[TokenNamespace] = field(init=False, default=None) 

79 components: Optional[TokenNamespace] = field(init=False, default=None) 

80 elevation: Optional[TokenNamespace] = field(init=False, default=None) 

81 opacity: Optional[TokenNamespace] = field(init=False, default=None) 

82 spacing: Optional[TokenNamespace] = field(init=False, default=None) 

83 

84 def __post_init__(self) -> None: 

85 """Initialize token namespaces from data dict.""" 

86 for key, value in self._data.items(): 

87 if isinstance(value, dict): 87 ↛ 90line 87 didn't jump to line 90 because the condition on line 87 was always true

88 setattr(self, key, TokenNamespace(value)) 

89 else: 

90 setattr(self, key, value) 

91 

92 @classmethod 

93 def from_dict(cls, data: Dict[str, Any]) -> Tokens: 

94 """Create Tokens from a dictionary. 

95 

96 Args: 

97 data: Dictionary containing token categories. 

98 

99 Returns: 

100 Tokens instance with parsed token namespaces. 

101 """ 

102 return cls(_data=data) 

103 

104 def to_dict(self) -> Dict[str, Any]: 

105 """Convert back to dictionary. 

106 

107 Returns: 

108 The underlying dictionary data. 

109 """ 

110 return self._data 

111 

112 

113@dataclass 

114class ThemeValue: 

115 """A single theme definition with metadata and tokens.""" 

116 

117 id: str 

118 label: str 

119 vendor: str 

120 appearance: Appearance 

121 tokens: Tokens 

122 description: Optional[str] = None 

123 icon_url: Optional[str] = None 

124 

125 @classmethod 

126 def from_dict(cls, data: Dict[str, Any]) -> ThemeValue: 

127 """Create ThemeValue from a dictionary. 

128 

129 Args: 

130 data: Dictionary containing theme metadata and tokens. 

131 

132 Returns: 

133 ThemeValue instance with parsed data. 

134 """ 

135 return cls( 

136 id=data["id"], 

137 label=data["label"], 

138 vendor=data["vendor"], 

139 appearance=Appearance(data["appearance"]), 

140 tokens=Tokens.from_dict(data["tokens"]), 

141 description=data.get("$description"), 

142 icon_url=data.get("iconUrl"), 

143 ) 

144 

145 def to_dict(self) -> Dict[str, Any]: 

146 """Convert back to dictionary. 

147 

148 Returns: 

149 Dictionary representation of the theme. 

150 """ 

151 result = { 

152 "id": self.id, 

153 "label": self.label, 

154 "vendor": self.vendor, 

155 "appearance": self.appearance.value, 

156 "tokens": self.tokens.to_dict(), 

157 } 

158 if self.description: 158 ↛ 160line 158 didn't jump to line 160 because the condition on line 158 was always true

159 result["$description"] = self.description 

160 if self.icon_url: 160 ↛ 162line 160 didn't jump to line 162 because the condition on line 160 was always true

161 result["iconUrl"] = self.icon_url 

162 return result 

163 

164 

165@dataclass 

166class ByVendorValue: 

167 """Vendor metadata.""" 

168 

169 name: str 

170 homepage: str 

171 themes: list[str] 

172 

173 @classmethod 

174 def from_dict(cls, data: Dict[str, Any]) -> ByVendorValue: 

175 """Create ByVendorValue from a dictionary. 

176 

177 Args: 

178 data: Dictionary containing vendor metadata. 

179 

180 Returns: 

181 ByVendorValue instance with parsed data. 

182 """ 

183 return cls( 

184 name=data["name"], 

185 homepage=data["homepage"], 

186 themes=data["themes"], 

187 ) 

188 

189 

190@dataclass 

191class Meta: 

192 """Metadata about the token collection.""" 

193 

194 theme_ids: list[str] = field(default_factory=list) 

195 vendors: list[str] = field(default_factory=list) 

196 total_themes: int = 0 

197 light_themes: int = 0 

198 dark_themes: int = 0 

199 

200 @classmethod 

201 def from_dict(cls, data: Dict[str, Any]) -> Meta: 

202 """Create Meta from a dictionary. 

203 

204 Args: 

205 data: Dictionary containing collection metadata. 

206 

207 Returns: 

208 Meta instance with parsed data. 

209 """ 

210 return cls( 

211 theme_ids=data.get("themeIds", []), 

212 vendors=data.get("vendors", []), 

213 total_themes=data.get("totalThemes", 0), 

214 light_themes=data.get("lightThemes", 0), 

215 dark_themes=data.get("darkThemes", 0), 

216 ) 

217 

218 

219@dataclass 

220class TurboThemes: 

221 """Root container for all themes and metadata.""" 

222 

223 themes: Dict[str, ThemeValue] 

224 by_vendor: Optional[Dict[str, ByVendorValue]] = None 

225 meta: Optional[Meta] = None 

226 schema: Optional[str] = None 

227 version: Optional[str] = None 

228 description: Optional[str] = None 

229 generated: Optional[datetime] = None 

230 

231 @classmethod 

232 def from_dict(cls, data: Dict[str, Any]) -> TurboThemes: 

233 """Create TurboThemes from a dictionary. 

234 

235 Args: 

236 data: Dictionary containing themes and metadata. 

237 

238 Returns: 

239 TurboThemes instance with parsed themes. 

240 """ 

241 themes = { 

242 theme_id: ThemeValue.from_dict(theme_data) 

243 for theme_id, theme_data in data.get("themes", {}).items() 

244 } 

245 

246 by_vendor = None 

247 if "byVendor" in data: 

248 by_vendor = { 

249 vendor_id: ByVendorValue.from_dict(vendor_data) 

250 for vendor_id, vendor_data in data["byVendor"].items() 

251 } 

252 

253 meta = None 

254 if "meta" in data: 

255 meta = Meta.from_dict(data["meta"]) 

256 

257 generated = None 

258 if "$generated" in data: 

259 try: 

260 generated = datetime.fromisoformat( 

261 data["$generated"].replace("Z", "+00:00") 

262 ) 

263 except (ValueError, AttributeError): 

264 pass 

265 

266 return cls( 

267 themes=themes, 

268 by_vendor=by_vendor, 

269 meta=meta, 

270 schema=data.get("$schema"), 

271 version=data.get("$version"), 

272 description=data.get("$description"), 

273 generated=generated, 

274 ) 

275 

276 

277def turbo_themes_from_dict(data: Dict[str, Any]) -> TurboThemes: 

278 """Create TurboThemes from a dictionary. 

279 

280 Compatibility function matching quicktype output. 

281 

282 Args: 

283 data: Dictionary containing themes and metadata. 

284 

285 Returns: 

286 TurboThemes instance with parsed themes. 

287 """ 

288 return TurboThemes.from_dict(data) 

289 

290 

291__all__ = [ 

292 "Appearance", 

293 "ByVendorValue", 

294 "Meta", 

295 "ThemeValue", 

296 "Tokens", 

297 "TokenNamespace", 

298 "TurboThemes", 

299 "turbo_themes_from_dict", 

300]