Coverage for src / lexigram / admin / services / component_registry.py: 28%

140 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Component registry for htpy components with lazy loading and versioning.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from datetime import datetime 

7from typing import TYPE_CHECKING, Any, TypeVar 

8 

9from lexigram.primitives.lazy import LazyImport 

10 

11T = TypeVar("T") 

12 

13 

14@dataclass 

15class ComponentMetadata: 

16 """Metadata for a registered component.""" 

17 

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 

27 

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 } 

40 

41 

42from lexigram.primitives.registry import Registry 

43 

44if TYPE_CHECKING: 

45 from collections.abc import Callable 

46 

47 

48class ComponentRegistry(Registry[str, ComponentMetadata]): 

49 """ 

50 Registry for htpy components with lazy loading support. 

51 

52 Provides component registration, discovery, versioning, and lazy loading 

53 capabilities for htpy-based UI components. 

54 """ 

55 

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] = {} 

61 

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 ) 

80 

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 ) 

89 

90 super().register(name, metadata) 

91 

92 # Register aliases 

93 if aliases: 

94 for alias in aliases: 

95 self._aliases[alias] = name 

96 

97 return metadata 

98 

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 ) 

117 

118 # Create lazy loader 

119 module_path, _class_name = import_path.rsplit(".", 1) 

120 lazy_loader = LazyImport(module_path) 

121 

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 ) 

130 

131 super().register(name, metadata) 

132 self._lazy_loaders[name] = lazy_loader 

133 

134 # Register aliases 

135 if aliases: 

136 for alias in aliases: 

137 self._aliases[alias] = name 

138 

139 return metadata 

140 

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] 

148 

149 metadata = super().get(name) 

150 if metadata is None: 

151 raise KeyError(f"Component '{name}' not found in registry") 

152 

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) 

159 

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] 

166 

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) 

172 

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

183 

184 metadata.deprecated = True 

185 metadata.replacement = replacement 

186 

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 = [] 

194 

195 for name, metadata in self.items(): 

196 # Skip deprecated unless requested 

197 if metadata.deprecated and not include_deprecated: 

198 continue 

199 

200 # Filter by tags 

201 if tags and not any(tag in metadata.tags for tag in tags): 

202 continue 

203 

204 components.append(name) 

205 

206 return sorted(components) 

207 

208 def get_metadata(self, name: str) -> ComponentMetadata: 

209 """Get component metadata.""" 

210 if name in self._aliases: 

211 name = self._aliases[name] 

212 

213 metadata = super().get(name) 

214 if metadata is None: 

215 raise KeyError(f"Component '{name}' not found") 

216 

217 return metadata 

218 

219 def get_version(self, name: str) -> str: 

220 """ 

221 Get component version. 

222 

223 Args: 

224 name: Component name 

225 

226 Returns: 

227 Version string 

228 """ 

229 return self.get_metadata(name).version 

230 

231 def check_version(self, name: str, required_version: str) -> bool: 

232 """ 

233 Check if component version meets requirement. 

234 

235 Simple version comparison (not full semantic versioning). 

236 

237 Args: 

238 name: Component name 

239 required_version: Required version (e.g., "1.0.0") 

240 

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 

246 

247 def _compare_versions(self, v1: str, v2: str) -> int: 

248 """ 

249 Compare two version strings. 

250 

251 Args: 

252 v1: First version 

253 v2: Second version 

254 

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(".")] 

260 

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

265 

266 for p1, p2 in zip(parts1, parts2, strict=False): 

267 if p1 < p2: 

268 return -1 

269 if p1 > p2: 

270 return 1 

271 

272 return 0 

273 

274 def clear(self) -> None: 

275 """Clear all registered components.""" 

276 super().clear() 

277 self._aliases.clear() 

278 self._lazy_loaders.clear() 

279 

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 } 

286 

287 

288_DEFAULT_REGISTRY: ComponentRegistry | None = None 

289 

290 

291def get_component_registry(context: Any | None = None) -> ComponentRegistry: 

292 """Get the component registry — from DI resolver if available, else the module singleton. 

293 

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 

301 

302 from lexigram.admin.lib.di import get_admin_resolver 

303 

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 

317 

318 

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. 

327 

328 Can be used as a decorator or function. 

329 

330 Usage: 

331 ```python 

332 # As decorator 

333 @register_component("button", version="1.0.0") 

334 class Button: 

335 pass 

336 

337 # As function 

338 register_component("button", Button, version="1.0.0") 

339 

340 # With lazy loading 

341 register_component( 

342 "data_table", 

343 import_path="lexigram.admin.ui.organisms.DataTable" 

344 ) 

345 ``` 

346 

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.) 

352 

353 Returns: 

354 Decorator function or None 

355 """ 

356 registry = get_component_registry() 

357 

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 

366 

367 # Used as decorator 

368 def decorator(cls: type) -> type: 

369 registry.register(name, cls, **kwargs) 

370 return cls 

371 

372 return decorator