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
« 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.
4Provides typed access to theme tokens loaded from tokens.json.
5Replaces the complex quicktype-generated types with a simpler implementation.
6"""
8from __future__ import annotations
10from dataclasses import dataclass, field
11from datetime import datetime
12from enum import Enum
13from typing import Any, Dict, Optional
16class Appearance(Enum):
17 """Theme appearance (light or dark)."""
19 LIGHT = "light"
20 DARK = "dark"
23class TokenNamespace:
24 """Dynamic namespace for accessing nested token values.
26 Converts dict keys to attributes for convenient access like:
27 tokens.background.base
28 tokens.text.primary
29 """
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)
39 def __repr__(self) -> str:
40 return f"TokenNamespace({self._data!r})"
42 def __getattr__(self, name: str) -> Any:
43 # Return None for missing attributes instead of raising
44 return None
46 def to_dict(self) -> Dict[str, Any]:
47 """Convert back to dictionary.
49 Returns:
50 The underlying dictionary data.
51 """
52 return self._data
55@dataclass
56class Tokens:
57 """Design tokens for a theme.
59 Provides attribute access to nested token categories:
60 tokens.background.base
61 tokens.text.primary
62 tokens.state.info
63 """
65 _data: Dict[str, Any] = field(repr=False)
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)
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)
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)
92 @classmethod
93 def from_dict(cls, data: Dict[str, Any]) -> Tokens:
94 """Create Tokens from a dictionary.
96 Args:
97 data: Dictionary containing token categories.
99 Returns:
100 Tokens instance with parsed token namespaces.
101 """
102 return cls(_data=data)
104 def to_dict(self) -> Dict[str, Any]:
105 """Convert back to dictionary.
107 Returns:
108 The underlying dictionary data.
109 """
110 return self._data
113@dataclass
114class ThemeValue:
115 """A single theme definition with metadata and tokens."""
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
125 @classmethod
126 def from_dict(cls, data: Dict[str, Any]) -> ThemeValue:
127 """Create ThemeValue from a dictionary.
129 Args:
130 data: Dictionary containing theme metadata and tokens.
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 )
145 def to_dict(self) -> Dict[str, Any]:
146 """Convert back to dictionary.
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
165@dataclass
166class ByVendorValue:
167 """Vendor metadata."""
169 name: str
170 homepage: str
171 themes: list[str]
173 @classmethod
174 def from_dict(cls, data: Dict[str, Any]) -> ByVendorValue:
175 """Create ByVendorValue from a dictionary.
177 Args:
178 data: Dictionary containing vendor metadata.
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 )
190@dataclass
191class Meta:
192 """Metadata about the token collection."""
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
200 @classmethod
201 def from_dict(cls, data: Dict[str, Any]) -> Meta:
202 """Create Meta from a dictionary.
204 Args:
205 data: Dictionary containing collection metadata.
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 )
219@dataclass
220class TurboThemes:
221 """Root container for all themes and metadata."""
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
231 @classmethod
232 def from_dict(cls, data: Dict[str, Any]) -> TurboThemes:
233 """Create TurboThemes from a dictionary.
235 Args:
236 data: Dictionary containing themes and metadata.
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 }
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 }
253 meta = None
254 if "meta" in data:
255 meta = Meta.from_dict(data["meta"])
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
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 )
277def turbo_themes_from_dict(data: Dict[str, Any]) -> TurboThemes:
278 """Create TurboThemes from a dictionary.
280 Compatibility function matching quicktype output.
282 Args:
283 data: Dictionary containing themes and metadata.
285 Returns:
286 TurboThemes instance with parsed themes.
287 """
288 return TurboThemes.from_dict(data)
291__all__ = [
292 "Appearance",
293 "ByVendorValue",
294 "Meta",
295 "ThemeValue",
296 "Tokens",
297 "TokenNamespace",
298 "TurboThemes",
299 "turbo_themes_from_dict",
300]