Coverage for src/turbo_themes/models.py: 97%
106 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 10:58 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 10:58 +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 enum import Enum
12from typing import Any
15class Appearance(Enum):
16 """Theme appearance (light or dark)."""
18 LIGHT = "light"
19 DARK = "dark"
22class TokenNamespace:
23 """Dynamic namespace for accessing nested token values.
25 Converts dict keys to attributes for convenient access like:
26 tokens.background.base
27 tokens.text.primary
28 """
30 def __init__(self, data: dict[str, Any]) -> None:
31 self._data = data
32 for key, value in data.items():
33 if isinstance(value, dict):
34 setattr(self, key, TokenNamespace(value))
35 else:
36 setattr(self, key, value)
38 def __repr__(self) -> str:
39 return f"TokenNamespace({self._data!r})"
41 def __getattr__(self, name: str) -> Any:
42 # Return None for missing attributes instead of raising
43 return None
45 def to_dict(self) -> dict[str, Any]:
46 """Convert back to dictionary.
48 Returns:
49 The underlying dictionary data.
50 """
51 return self._data
54@dataclass
55class Tokens:
56 """Design tokens for a theme.
58 Provides attribute access to nested token categories:
59 tokens.background.base
60 tokens.text.primary
61 tokens.state.info
62 """
64 _data: dict[str, Any] = field(repr=False)
66 # Core token categories (always present)
67 accent: TokenNamespace = field(init=False)
68 background: TokenNamespace = field(init=False)
69 border: TokenNamespace = field(init=False)
70 brand: TokenNamespace = field(init=False)
71 content: TokenNamespace = field(init=False)
72 state: TokenNamespace = field(init=False)
73 text: TokenNamespace = field(init=False)
74 typography: TokenNamespace = field(init=False)
76 # Optional token categories
77 animation: TokenNamespace | None = field(init=False, default=None)
78 components: TokenNamespace | None = field(init=False, default=None)
79 elevation: TokenNamespace | None = field(init=False, default=None)
80 opacity: TokenNamespace | None = field(init=False, default=None)
81 spacing: TokenNamespace | None = field(init=False, default=None)
83 def __post_init__(self) -> None:
84 """Initialize token namespaces from data dict."""
85 for key, value in self._data.items():
86 if isinstance(value, dict): 86 ↛ 89line 86 didn't jump to line 89 because the condition on line 86 was always true
87 setattr(self, key, TokenNamespace(value))
88 else:
89 setattr(self, key, value)
91 @classmethod
92 def from_dict(cls, data: dict[str, Any]) -> Tokens:
93 """Create Tokens from a dictionary.
95 Args:
96 data: Dictionary containing token categories.
98 Returns:
99 Tokens instance with parsed token namespaces.
100 """
101 return cls(_data=data)
103 def to_dict(self) -> dict[str, Any]:
104 """Convert back to dictionary.
106 Returns:
107 The underlying dictionary data.
108 """
109 return self._data
112@dataclass
113class ThemeValue:
114 """A single theme definition with metadata and tokens."""
116 id: str
117 label: str
118 vendor: str
119 appearance: Appearance
120 tokens: Tokens
121 description: str | None = None
122 icon_url: str | None = None
124 @classmethod
125 def from_dict(cls, data: dict[str, Any]) -> ThemeValue:
126 """Create ThemeValue from a dictionary.
128 Args:
129 data: Dictionary containing theme metadata and tokens.
131 Returns:
132 ThemeValue instance with parsed data.
133 """
134 return cls(
135 id=data["id"],
136 label=data["label"],
137 vendor=data["vendor"],
138 appearance=Appearance(data["appearance"]),
139 tokens=Tokens.from_dict(data["tokens"]),
140 description=data.get("$description"),
141 icon_url=data.get("iconUrl"),
142 )
144 def to_dict(self) -> dict[str, Any]:
145 """Convert back to dictionary.
147 Returns:
148 Dictionary representation of the theme.
149 """
150 result = {
151 "id": self.id,
152 "label": self.label,
153 "vendor": self.vendor,
154 "appearance": self.appearance.value,
155 "tokens": self.tokens.to_dict(),
156 }
157 if self.description: 157 ↛ 159line 157 didn't jump to line 159 because the condition on line 157 was always true
158 result["$description"] = self.description
159 if self.icon_url: 159 ↛ 161line 159 didn't jump to line 161 because the condition on line 159 was always true
160 result["iconUrl"] = self.icon_url
161 return result
164@dataclass
165class ByVendorValue:
166 """Vendor metadata."""
168 name: str
169 homepage: str
170 themes: list[str]
172 @classmethod
173 def from_dict(cls, data: dict[str, Any]) -> ByVendorValue:
174 """Create ByVendorValue from a dictionary.
176 Args:
177 data: Dictionary containing vendor metadata.
179 Returns:
180 ByVendorValue instance with parsed data.
181 """
182 return cls(
183 name=data["name"],
184 homepage=data["homepage"],
185 themes=data["themes"],
186 )
189@dataclass
190class Meta:
191 """Metadata about the token collection."""
193 theme_ids: list[str] = field(default_factory=list)
194 vendors: list[str] = field(default_factory=list)
195 total_themes: int = 0
196 light_themes: int = 0
197 dark_themes: int = 0
199 @classmethod
200 def from_dict(cls, data: dict[str, Any]) -> Meta:
201 """Create Meta from a dictionary.
203 Args:
204 data: Dictionary containing collection metadata.
206 Returns:
207 Meta instance with parsed data.
208 """
209 return cls(
210 theme_ids=data.get("themeIds", []),
211 vendors=data.get("vendors", []),
212 total_themes=data.get("totalThemes", 0),
213 light_themes=data.get("lightThemes", 0),
214 dark_themes=data.get("darkThemes", 0),
215 )
218@dataclass
219class TurboThemes:
220 """Root container for all themes and metadata."""
222 themes: dict[str, ThemeValue]
223 by_vendor: dict[str, ByVendorValue] | None = None
224 meta: Meta | None = None
225 schema: str | None = None
226 version: str | None = None
227 description: str | None = None
228 generated: str | None = None
230 @classmethod
231 def from_dict(cls, data: dict[str, Any]) -> TurboThemes:
232 """Create TurboThemes from a dictionary.
234 Args:
235 data: Dictionary containing themes and metadata.
237 Returns:
238 TurboThemes instance with parsed themes.
239 """
240 themes = {
241 theme_id: ThemeValue.from_dict(theme_data)
242 for theme_id, theme_data in data.get("themes", {}).items()
243 }
245 by_vendor = None
246 if "byVendor" in data:
247 by_vendor = {
248 vendor_id: ByVendorValue.from_dict(vendor_data)
249 for vendor_id, vendor_data in data["byVendor"].items()
250 }
252 meta = None
253 if "meta" in data:
254 meta = Meta.from_dict(data["meta"])
256 generated = data.get("$generated")
258 return cls(
259 themes=themes,
260 by_vendor=by_vendor,
261 meta=meta,
262 schema=data.get("$schema"),
263 version=data.get("$version"),
264 description=data.get("$description"),
265 generated=generated,
266 )
269def turbo_themes_from_dict(data: dict[str, Any]) -> TurboThemes:
270 """Create TurboThemes from a dictionary.
272 Compatibility function matching quicktype output.
274 Args:
275 data: Dictionary containing themes and metadata.
277 Returns:
278 TurboThemes instance with parsed themes.
279 """
280 return TurboThemes.from_dict(data)
283__all__ = [
284 "Appearance",
285 "ByVendorValue",
286 "Meta",
287 "ThemeValue",
288 "TokenNamespace",
289 "Tokens",
290 "TurboThemes",
291 "turbo_themes_from_dict",
292]