Coverage for src/lexigram/admin/services/component_registry.py: 91%
140 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Component registry for htpy components with lazy loading and versioning."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from datetime import datetime
7from typing import TYPE_CHECKING, Any, TypeVar
9from lexigram.primitives.lazy import LazyImport
11T = TypeVar("T")
14@dataclass
15class ComponentMetadata:
16 """Metadata for a registered component."""
18 name: str
19 component_class: type | str # Class or import path
20 version: str = "1.0.0"
21 description: str = ""
22 tags: list[str] = field(default_factory=list)
23 registered_at: datetime = field(default_factory=datetime.now)
24 lazy: bool = False
25 deprecated: bool = False
26 replacement: str | None = None
28 def to_dict(self) -> dict[str, Any]:
29 """Convert metadata to dictionary."""
30 return {
31 "name": self.name,
32 "version": self.version,
33 "description": self.description,
34 "tags": self.tags,
35 "registered_at": self.registered_at.isoformat(),
36 "lazy": self.lazy,
37 "deprecated": self.deprecated,
38 "replacement": self.replacement,
39 }
42from lexigram.primitives.registry import Registry
44if TYPE_CHECKING:
45 from collections.abc import Callable
48class ComponentRegistry(Registry[str, ComponentMetadata]):
49 """
50 Registry for htpy components with lazy loading support.
52 Provides component registration, discovery, versioning, and lazy loading
53 capabilities for htpy-based UI components.
54 """
56 def __init__(self) -> None:
57 """Initialize component registry."""
58 super().__init__(name="components")
59 self._aliases: dict[str, str] = {}
60 self._lazy_loaders: dict[str, LazyImport] = {}
62 def register( # type: ignore[override]
63 self,
64 name: str,
65 component_class: type,
66 version: str = "1.0.0",
67 description: str = "",
68 tags: list[str] | None = None,
69 aliases: list[str] | None = None,
70 ) -> ComponentMetadata:
71 """
72 Register a component with the registry.
73 """
74 if name in self._items:
75 existing = self._items[name]
76 if not existing.deprecated:
77 raise ValueError(
78 f"Component '{name}' already registered at version {existing.version}",
79 )
81 metadata = ComponentMetadata(
82 name=name,
83 component_class=component_class,
84 version=version,
85 description=description,
86 tags=tags or [],
87 lazy=False,
88 )
90 super().register(name, metadata)
92 # Register aliases
93 if aliases:
94 for alias in aliases:
95 self._aliases[alias] = name
97 return metadata
99 def register_lazy(
100 self,
101 name: str,
102 import_path: str,
103 version: str = "1.0.0",
104 description: str = "",
105 tags: list[str] | None = None,
106 aliases: list[str] | None = None,
107 ) -> ComponentMetadata:
108 """
109 Register a component for lazy loading.
110 """
111 if name in self._items:
112 existing = self._items[name]
113 if not existing.deprecated:
114 raise ValueError(
115 f"Component '{name}' already registered at version {existing.version}",
116 )
118 # Create lazy loader
119 module_path, _class_name = import_path.rsplit(".", 1)
120 lazy_loader = LazyImport(module_path)
122 metadata = ComponentMetadata(
123 name=name,
124 component_class=import_path,
125 version=version,
126 description=description,
127 tags=tags or [],
128 lazy=True,
129 )
131 super().register(name, metadata)
132 self._lazy_loaders[name] = lazy_loader
134 # Register aliases
135 if aliases:
136 for alias in aliases:
137 self._aliases[alias] = name
139 return metadata
141 def get(self, name: str) -> type: # type: ignore[override]
142 """
143 Get a component by name.
144 """
145 # Resolve alias
146 if name in self._aliases:
147 name = self._aliases[name]
149 metadata = super().get(name)
150 if metadata is None:
151 raise KeyError(f"Component '{name}' not found in registry")
153 # Check if deprecated
154 if metadata.deprecated:
155 msg = f"Component '{name}' is deprecated"
156 if metadata.replacement:
157 msg += f". Use '{metadata.replacement}' instead"
158 raise ValueError(msg)
160 # Handle lazy loading
161 if metadata.lazy:
162 lazy_loader = self._lazy_loaders[name]
163 _module_path, class_name = str(metadata.component_class).rsplit(".", 1)
164 return getattr(lazy_loader, class_name)
165 return metadata.component_class # type: ignore[return-value]
167 def has(self, name: str) -> bool:
168 """Check if component is registered."""
169 if name in self._aliases:
170 name = self._aliases[name]
171 return super().has(name)
173 def deprecate(
174 self,
175 name: str,
176 replacement: str | None = None,
177 message: str | None = None,
178 ) -> None:
179 """Mark a component as deprecated."""
180 metadata = super().get(name)
181 if metadata is None:
182 raise KeyError(f"Component '{name}' not found")
184 metadata.deprecated = True
185 metadata.replacement = replacement
187 def list_components(
188 self,
189 tags: list[str] | None = None,
190 include_deprecated: bool = False,
191 ) -> list[str]:
192 """List all registered component names."""
193 components = []
195 for name, metadata in self.items():
196 # Skip deprecated unless requested
197 if metadata.deprecated and not include_deprecated:
198 continue
200 # Filter by tags
201 if tags and not any(tag in metadata.tags for tag in tags):
202 continue
204 components.append(name)
206 return sorted(components)
208 def get_metadata(self, name: str) -> ComponentMetadata:
209 """Get component metadata."""
210 if name in self._aliases:
211 name = self._aliases[name]
213 metadata = super().get(name)
214 if metadata is None:
215 raise KeyError(f"Component '{name}' not found")
217 return metadata
219 def get_version(self, name: str) -> str:
220 """
221 Get component version.
223 Args:
224 name: Component name
226 Returns:
227 Version string
228 """
229 return self.get_metadata(name).version
231 def check_version(self, name: str, required_version: str) -> bool:
232 """
233 Check if component version meets requirement.
235 Simple version comparison (not full semantic versioning).
237 Args:
238 name: Component name
239 required_version: Required version (e.g., "1.0.0")
241 Returns:
242 True if version matches or is newer
243 """
244 current = self.get_version(name)
245 return self._compare_versions(current, required_version) >= 0
247 def _compare_versions(self, v1: str, v2: str) -> int:
248 """
249 Compare two version strings.
251 Args:
252 v1: First version
253 v2: Second version
255 Returns:
256 -1 if v1 < v2, 0 if equal, 1 if v1 > v2
257 """
258 parts1 = [int(x) for x in v1.split(".")]
259 parts2 = [int(x) for x in v2.split(".")]
261 # Pad to same length
262 max_len = max(len(parts1), len(parts2))
263 parts1.extend([0] * (max_len - len(parts1)))
264 parts2.extend([0] * (max_len - len(parts2)))
266 for p1, p2 in zip(parts1, parts2, strict=False):
267 if p1 < p2:
268 return -1
269 if p1 > p2:
270 return 1
272 return 0
274 def clear(self) -> None:
275 """Clear all registered components."""
276 super().clear()
277 self._aliases.clear()
278 self._lazy_loaders.clear()
280 def export_manifest(self) -> dict[str, Any]:
281 """Export registry as manifest."""
282 return {
283 "components": {name: metadata.to_dict() for name, metadata in self.items()},
284 "aliases": self._aliases.copy(),
285 }
288_DEFAULT_REGISTRY: ComponentRegistry | None = None
291def get_component_registry(context: Any | None = None) -> ComponentRegistry:
292 """Get the component registry — from DI resolver if available, else the module singleton.
294 When a resolver is present in the current admin context (e.g. inside a
295 request or application scope), the registry is resolved from the DI
296 container. Otherwise a module-level singleton is returned so that the
297 registry is usable in tests and standalone scripts without a running
298 application.
299 """
300 global _DEFAULT_REGISTRY
302 from lexigram.admin.lib.di import get_admin_resolver
304 try:
305 resolver = get_admin_resolver(context)
306 registry = resolver.resolve_sync(ComponentRegistry) # type: ignore[attr-defined]
307 if hasattr(registry, "__await__"):
308 raise RuntimeError(
309 "Resolved coroutine instead of ComponentRegistry directly",
310 )
311 return registry
312 except RuntimeError:
313 # No resolver in scope — fall back to the module-level singleton
314 if _DEFAULT_REGISTRY is None:
315 _DEFAULT_REGISTRY = ComponentRegistry()
316 return _DEFAULT_REGISTRY
319def register_component(
320 name: str,
321 component_class: type | None = None,
322 import_path: str | None = None,
323 **kwargs: Any,
324) -> Callable[[type], type] | None:
325 """
326 Decorator to register a component.
328 Can be used as a decorator or function.
330 Usage:
331 ```python
332 # As decorator
333 @register_component("button", version="1.0.0")
334 class Button:
335 pass
337 # As function
338 register_component("button", Button, version="1.0.0")
340 # With lazy loading
341 register_component(
342 "data_table",
343 import_path="lexigram.admin.ui.organisms.DataTable"
344 )
345 ```
347 Args:
348 name: Component name
349 component_class: Component class (for direct registration)
350 import_path: Import path (for lazy loading)
351 **kwargs: Additional metadata (version, description, tags, etc.)
353 Returns:
354 Decorator function or None
355 """
356 registry = get_component_registry()
358 if component_class is not None:
359 # Direct registration
360 registry.register(name, component_class, **kwargs)
361 return None
362 if import_path is not None:
363 # Lazy registration
364 registry.register_lazy(name, import_path, **kwargs)
365 return None
367 # Used as decorator
368 def decorator(cls: type) -> type:
369 registry.register(name, cls, **kwargs)
370 return cls
372 return decorator