Coverage for fastblocks/adapters/templates/htmy.py: 26%

380 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -0700

1"""HTMY Templates Adapter for FastBlocks. 

2 

3Provides native HTMY component rendering with advanced features including: 

4- Component discovery and caching system 

5- Multi-layer caching (Redis, cloud storage, filesystem) 

6- Bidirectional integration with Jinja2 templates 

7- Async component rendering with context sharing 

8- Template synchronization across cache/storage/filesystem layers 

9- Enhanced debugging and error handling 

10 

11Requirements: 

12- htmy>=0.1.0 

13- redis>=3.5.3 (for caching) 

14 

15Usage: 

16```python 

17from acb.depends import Inject, depends 

18 

19htmy = depends.get("htmy") 

20 

21HTMYTemplates = import_adapter("htmy") 

22 

23response = await htmy.render_component(request, "my_component", {"data": data}) 

24 

25component_class = await htmy.get_component_class("my_component") 

26``` 

27 

28Author: lesleslie <les@wedgwoodwebworks.com> 

29Created: 2025-01-13 

30""" 

31 

32import asyncio 

33import typing as t 

34from contextlib import suppress 

35from enum import Enum 

36from typing import TYPE_CHECKING, Any 

37from uuid import UUID 

38 

39# Handle imports with fallback for different ACB versions 

40# Import all names in a single try-except block 

41imports_successful = False 

42try: 

43 from acb.adapters import AdapterStatus as _AdapterStatus 

44 from acb.adapters import get_adapter as _get_adapter 

45 from acb.adapters import import_adapter as _import_adapter 

46 from acb.adapters import root_path as _root_path 

47 

48 imports_successful = True 

49except ImportError: 

50 _AdapterStatus = None 

51 _get_adapter = None 

52 _import_adapter = None 

53 _root_path = None 

54 

55# Assign the imported names or fallbacks 

56if imports_successful: 

57 AdapterStatus = _AdapterStatus 

58 get_adapter = _get_adapter 

59 import_adapter = _import_adapter 

60 root_path = _root_path 

61else: 

62 # Define fallbacks 

63 class _FallbackAdapterStatus(Enum): 

64 ALPHA = "alpha" 

65 BETA = "beta" 

66 STABLE = "stable" 

67 DEPRECATED = "deprecated" 

68 EXPERIMENTAL = "experimental" 

69 

70 AdapterStatus = _FallbackAdapterStatus 

71 get_adapter = None 

72 import_adapter = None 

73 root_path = None 

74from acb.debug import debug 

75from acb.depends import depends 

76from anyio import Path as AsyncPath 

77from starlette.responses import HTMLResponse 

78 

79from ._base import TemplatesBase, TemplatesBaseSettings 

80from ._htmy_components import ( 

81 AdvancedHTMYComponentRegistry, 

82 ComponentLifecycleManager, 

83 ComponentMetadata, 

84 ComponentRenderError, 

85 ComponentStatus, 

86 ComponentType, 

87) 

88 

89if TYPE_CHECKING: 

90 from fastblocks.actions.sync.strategies import SyncDirection, SyncStrategy 

91 from fastblocks.actions.sync.templates import sync_templates 

92 

93try: 

94 from fastblocks.actions.sync.strategies import SyncDirection, SyncStrategy 

95 from fastblocks.actions.sync.templates import sync_templates 

96except ImportError: 

97 sync_templates: t.Callable[..., t.Any] | None = None # type: ignore[no-redef] 

98 SyncDirection: type[Enum] | None = None # type: ignore[no-redef] 

99 SyncStrategy: type[object] | None = None # type: ignore[no-redef] 

100 

101try: 

102 Cache, Storage, Models = import_adapter() 

103except Exception: 

104 Cache = Storage = Models = None 

105 

106 

107class ComponentNotFound(Exception): 

108 pass 

109 

110 

111class ComponentCompilationError(Exception): 

112 pass 

113 

114 

115class HTMYComponentRegistry: 

116 def __init__( 

117 self, 

118 searchpaths: list[AsyncPath] | None = None, 

119 cache: t.Any = None, 

120 storage: t.Any = None, 

121 ) -> None: 

122 self.searchpaths = searchpaths or [] 

123 self.cache = cache 

124 self.storage = storage 

125 self._component_cache: dict[str, t.Any] = {} 

126 self._source_cache: dict[str, str] = {} 

127 

128 @staticmethod 

129 def get_cache_key(component_path: AsyncPath, cache_type: str = "source") -> str: 

130 return f"htmy_component_{cache_type}:{component_path}" 

131 

132 @staticmethod 

133 def get_storage_path(component_path: AsyncPath) -> AsyncPath: 

134 return component_path 

135 

136 async def discover_components(self) -> dict[str, AsyncPath]: 

137 components = {} 

138 for search_path in self.searchpaths: 

139 if not await search_path.exists(): 

140 continue 

141 async for component_file in search_path.rglob("*.py"): 

142 if component_file.name == "__init__.py": 

143 continue 

144 component_name = component_file.stem 

145 components[component_name] = component_file 

146 

147 return components 

148 

149 async def _cache_component_source( 

150 self, component_path: AsyncPath, source: str 

151 ) -> None: 

152 if self.cache is not None: 

153 cache_key = self.get_cache_key(component_path) 

154 await self.cache.set(cache_key, source.encode()) 

155 

156 async def _cache_component_bytecode( 

157 self, component_path: AsyncPath, bytecode: bytes 

158 ) -> None: 

159 if self.cache is not None: 

160 cache_key = self.get_cache_key(component_path, "bytecode") 

161 await self.cache.set(cache_key, bytecode) 

162 

163 async def _get_cached_source(self, component_path: AsyncPath) -> str | None: 

164 if self.cache is not None: 

165 cache_key = self.get_cache_key(component_path) 

166 cached = await self.cache.get(cache_key) 

167 if cached: 

168 return t.cast(str, cached.decode()) 

169 return None 

170 

171 async def _get_cached_bytecode(self, component_path: AsyncPath) -> bytes | None: 

172 if self.cache is not None: 

173 cache_key = self.get_cache_key(component_path, "bytecode") 

174 result = await self.cache.get(cache_key) 

175 return result # type: ignore[no-any-return] 

176 return None 

177 

178 async def _sync_component_file( 

179 self, 

180 path: AsyncPath, 

181 storage_path: AsyncPath, 

182 ) -> tuple[str, int]: 

183 if sync_templates is None or SyncDirection is None or SyncStrategy is None: 

184 return await self._sync_from_storage_fallback(path, storage_path) 

185 

186 try: 

187 strategy = SyncStrategy(backup_on_conflict=False) 

188 component_paths = [path] 

189 result = await sync_templates( 

190 template_paths=component_paths, 

191 strategy=strategy, 

192 ) 

193 

194 source = await path.read_text() 

195 local_stat = await path.stat() 

196 local_mtime = int(local_stat.st_mtime) 

197 

198 debug(f"Component sync result: {result.sync_status} for {path}") 

199 return source, local_mtime 

200 

201 except Exception as e: 

202 debug(f"Sync action failed for {path}: {e}, falling back to primitive sync") 

203 return await self._sync_from_storage_fallback(path, storage_path) 

204 

205 async def _sync_from_storage_fallback( 

206 self, 

207 path: AsyncPath, 

208 storage_path: AsyncPath, 

209 ) -> tuple[str, int]: 

210 local_stat = await path.stat() 

211 local_mtime = int(local_stat.st_mtime) 

212 

213 if self.storage is not None: 

214 try: 

215 local_size = local_stat.st_size 

216 storage_stat = await self.storage.templates.stat(storage_path) 

217 storage_mtime = round(storage_stat.get("mtime", 0)) 

218 storage_size = storage_stat.get("size", 0) 

219 

220 if local_mtime < storage_mtime and local_size != storage_size: 

221 resp = await self.storage.templates.open(storage_path) 

222 await path.write_bytes(resp) 

223 source = resp.decode() 

224 return source, storage_mtime 

225 except Exception as e: 

226 debug(f"Storage fallback failed for {path}: {e}") 

227 

228 source = await path.read_text() 

229 return source, local_mtime 

230 

231 async def get_component_source(self, component_name: str) -> tuple[str, AsyncPath]: 

232 components = await self.discover_components() 

233 if component_name not in components: 

234 raise ComponentNotFound(f"Component '{component_name}' not found") 

235 component_path = components[component_name] 

236 cache_key = str(component_path) 

237 if cache_key in self._source_cache: 

238 return self._source_cache[cache_key], component_path 

239 cached_source = await self._get_cached_source(component_path) 

240 if cached_source: 

241 self._source_cache[cache_key] = cached_source 

242 return cached_source, component_path 

243 storage_path = self.get_storage_path(component_path) 

244 source, _ = await self._sync_component_file(component_path, storage_path) 

245 self._source_cache[cache_key] = source 

246 await self._cache_component_source(component_path, source) 

247 

248 return source, component_path 

249 

250 async def get_component_class(self, component_name: str) -> t.Any: 

251 if component_name in self._component_cache: 

252 return self._component_cache[component_name] 

253 source, component_path = await self.get_component_source(component_name) 

254 cached_bytecode = await self._get_cached_bytecode(component_path) 

255 try: 

256 if cached_bytecode: 

257 try: 

258 import pickle 

259 

260 component_class = pickle.loads(cached_bytecode) 

261 self._component_cache[component_name] = component_class 

262 return component_class 

263 except Exception as e: 

264 debug(f"Failed to load cached bytecode for {component_name}: {e}") 

265 

266 namespace: dict[str, t.Any] = {} 

267 compiled_code = compile(source, str(component_path), "exec") 

268 # nosec B102 - This exec is used for loading trusted HTMY component files 

269 # In a production environment, these files should be validated/sanitized 

270 exec(compiled_code, namespace) 

271 component_class = None 

272 for obj in namespace.values(): 

273 if hasattr(obj, "htmy") and callable(getattr(obj, "htmy")): 

274 component_class = obj 

275 break 

276 except Exception as e: 

277 raise ComponentCompilationError( 

278 f"Failed to compile component '{component_name}': {e}" 

279 ) from e 

280 

281 

282class HTMYTemplatesSettings(TemplatesBaseSettings): 

283 searchpaths: list[str] = [] 

284 cache_timeout: int = 300 

285 enable_bidirectional: bool = True 

286 debug_components: bool = False 

287 enable_hot_reload: bool = True 

288 enable_lifecycle_hooks: bool = True 

289 enable_component_validation: bool = True 

290 enable_advanced_registry: bool = True 

291 

292 

293class HTMYTemplates(TemplatesBase): 

294 def __init__(self, **kwargs: t.Any) -> None: 

295 super().__init__(**kwargs) 

296 self.htmy_registry: HTMYComponentRegistry | None = None 

297 self.advanced_registry: AdvancedHTMYComponentRegistry | None = None 

298 self.component_searchpaths: list[AsyncPath] = [] 

299 self.jinja_templates: t.Any = None 

300 self.settings = HTMYTemplatesSettings(**kwargs) 

301 

302 async def get_component_searchpaths(self, app_adapter: t.Any) -> list[AsyncPath]: 

303 searchpaths = [] 

304 if callable(root_path): 

305 base_root = AsyncPath(root_path()) 

306 else: 

307 base_root = AsyncPath(root_path) 

308 debug(f"get_component_searchpaths: app_adapter={app_adapter}") 

309 if app_adapter: 

310 category = getattr(app_adapter, "category", "app") 

311 debug(f"get_component_searchpaths: using category={category}") 

312 template_paths = self.get_searchpath( 

313 app_adapter, base_root / "templates" / category 

314 ) 

315 debug(f"get_component_searchpaths: template_paths={template_paths}") 

316 for template_path in template_paths: 

317 component_path = template_path / "components" 

318 searchpaths.append(component_path) 

319 debug( 

320 f"get_component_searchpaths: added component_path={component_path}" 

321 ) 

322 debug(f"get_component_searchpaths: final searchpaths={searchpaths}") 

323 return searchpaths 

324 

325 async def _init_htmy_registry(self) -> None: 

326 if self.htmy_registry is not None and self.advanced_registry is not None: 

327 return 

328 

329 app_adapter = get_adapter("app") 

330 if app_adapter is None: 

331 try: 

332 app_adapter = depends.get("app") 

333 except Exception: 

334 from types import SimpleNamespace 

335 

336 app_adapter = SimpleNamespace(name="app", category="app") 

337 

338 self.component_searchpaths = await self.get_component_searchpaths(app_adapter) 

339 

340 # Initialize advanced registry if enabled 

341 if self.settings.enable_advanced_registry: 

342 self.advanced_registry = AdvancedHTMYComponentRegistry( 

343 searchpaths=self.component_searchpaths, 

344 cache=self.cache, 

345 storage=self.storage, 

346 ) 

347 

348 # Configure hot reload 

349 if self.settings.enable_hot_reload: 

350 self.advanced_registry.enable_hot_reload() 

351 

352 # Keep legacy registry for backward compatibility 

353 self.htmy_registry = HTMYComponentRegistry( 

354 searchpaths=self.component_searchpaths, 

355 cache=self.cache, 

356 storage=self.storage, 

357 ) 

358 

359 async def clear_component_cache(self, component_name: str | None = None) -> None: 

360 if self.htmy_registry is None: 

361 return 

362 if component_name: 

363 self.htmy_registry._component_cache.pop(component_name, None) 

364 if self.cache: 

365 components = await self.htmy_registry.discover_components() 

366 if component_name in components: 

367 component_path = components[component_name] 

368 source_key = HTMYComponentRegistry.get_cache_key(component_path) 

369 bytecode_key = HTMYComponentRegistry.get_cache_key( 

370 component_path, "bytecode" 

371 ) 

372 await self.cache.delete(source_key) 

373 await self.cache.delete(bytecode_key) 

374 debug(f"HTMY component cache cleared for: {component_name}") 

375 else: 

376 self.htmy_registry._component_cache.clear() 

377 self.htmy_registry._source_cache.clear() 

378 if self.cache: 

379 with suppress(NotImplementedError, AttributeError): 

380 await self.cache.clear("htmy_component_source") 

381 await self.cache.clear("htmy_component_bytecode") 

382 debug("All HTMY component caches cleared") 

383 

384 async def get_component_class(self, component_name: str) -> t.Any: 

385 if self.htmy_registry is None: 

386 await self._init_htmy_registry() 

387 

388 if self.htmy_registry is not None: 

389 return await self.htmy_registry.get_component_class(component_name) 

390 raise ComponentNotFound( 

391 f"Component registry not initialized for '{component_name}'" 

392 ) 

393 

394 async def render_component_advanced( 

395 self, 

396 request: t.Any, 

397 component: str, 

398 context: dict[str, t.Any] | None = None, 

399 status_code: int = 200, 

400 headers: dict[str, str] | None = None, 

401 **kwargs: t.Any, 

402 ) -> HTMLResponse: 

403 """Render component using advanced registry with lifecycle management.""" 

404 if context is None: 

405 context = {} 

406 if headers is None: 

407 headers = {} 

408 

409 if self.advanced_registry is None: 

410 await self._init_htmy_registry() 

411 

412 if self.advanced_registry is None: 

413 raise ComponentRenderError( 

414 f"Advanced registry not initialized for '{component}'" 

415 ) 

416 

417 try: 

418 # Add kwargs to context 

419 enhanced_context = context | kwargs 

420 

421 rendered_content = ( 

422 await self.advanced_registry.render_component_with_lifecycle( 

423 component, enhanced_context, request 

424 ) 

425 ) 

426 

427 return HTMLResponse( 

428 content=rendered_content, 

429 status_code=status_code, 

430 headers=headers, 

431 ) 

432 

433 except Exception as e: 

434 error_content = ( 

435 f"<html><body>Component {component} error: {e}</body></html>" 

436 ) 

437 if self.settings.debug_components: 

438 import traceback 

439 

440 error_content = f"<html><body><h3>Component {component} error:</h3><pre>{traceback.format_exc()}</pre></body></html>" 

441 

442 return HTMLResponse( 

443 content=error_content, 

444 status_code=500, 

445 headers=headers, 

446 ) 

447 

448 async def render_component( 

449 self, 

450 request: t.Any, 

451 component: str, 

452 context: dict[str, t.Any] | None = None, 

453 status_code: int = 200, 

454 headers: dict[str, str] | None = None, 

455 **kwargs: t.Any, 

456 ) -> HTMLResponse: 

457 if context is None: 

458 context = {} 

459 if headers is None: 

460 headers = {} 

461 

462 # Use advanced registry if available and enabled 

463 if ( 

464 self.settings.enable_advanced_registry 

465 and self.advanced_registry is not None 

466 ): 

467 return await self.render_component_advanced( 

468 request, component, context, status_code, headers, **kwargs 

469 ) 

470 

471 if self.htmy_registry is None: 

472 await self._init_htmy_registry() 

473 

474 if self.htmy_registry is None: 

475 raise ComponentNotFound( 

476 f"Component registry not initialized for '{component}'" 

477 ) 

478 

479 try: 

480 component_class = await self.htmy_registry.get_component_class(component) 

481 

482 component_instance = component_class(**context, **kwargs) 

483 

484 htmy_context = { 

485 "request": request, 

486 **context, 

487 "render_template": self._create_template_renderer(request), 

488 "render_block": self._create_block_renderer(request), 

489 "_template_system": "htmy", 

490 "_request": request, 

491 } 

492 

493 if asyncio.iscoroutinefunction(component_instance.htmy): 

494 rendered_content = await component_instance.htmy(htmy_context) 

495 else: 

496 rendered_content = component_instance.htmy(htmy_context) 

497 

498 html_content = str(rendered_content) 

499 

500 return HTMLResponse( 

501 content=html_content, 

502 status_code=status_code, 

503 headers=headers, 

504 ) 

505 

506 except (ComponentNotFound, ComponentCompilationError) as e: 

507 return HTMLResponse( 

508 content=f"<html><body>Component {component} error: {e}</body></html>", 

509 status_code=404, 

510 headers=headers, 

511 ) 

512 

513 def _create_template_renderer( 

514 self, request: t.Any = None 

515 ) -> t.Callable[..., t.Any]: 

516 async def render_template( 

517 template_name: str, 

518 context: dict[str, t.Any] | None = None, 

519 inherit_context: bool = True, # noqa: ARG001 

520 **kwargs: t.Any, 

521 ) -> str: 

522 if context is None: 

523 context = {} 

524 

525 template_context = context | kwargs 

526 

527 if self.jinja_templates and hasattr(self.jinja_templates, "app"): 

528 try: 

529 template = self.jinja_templates.app.get_template(template_name) 

530 if asyncio.iscoroutinefunction(template.render): 

531 rendered = await template.render(template_context) 

532 else: 

533 rendered = template.render(template_context) 

534 return rendered # type: ignore[no-any-return] 

535 except Exception as e: 

536 debug( 

537 f"Failed to render template '{template_name}' in HTMY component: {e}" 

538 ) 

539 return f"<!-- Error rendering template '{template_name}': {e} -->" 

540 else: 

541 debug( 

542 f"No Jinja2 adapter available to render template '{template_name}' in HTMY component" 

543 ) 

544 return f"<!-- No template renderer available for '{template_name}' -->" 

545 

546 return render_template 

547 

548 async def discover_components(self) -> dict[str, ComponentMetadata]: 

549 """Discover all components and return metadata.""" 

550 if self.advanced_registry is None: 

551 await self._init_htmy_registry() 

552 

553 if self.advanced_registry is not None: 

554 return await self.advanced_registry.discover_components() 

555 

556 # Fallback to basic discovery 

557 components = {} 

558 if self.htmy_registry is not None: 

559 discovered = await self.htmy_registry.discover_components() 

560 for name, path in discovered.items(): 

561 components[name] = ComponentMetadata( 

562 name=name, 

563 path=path, 

564 type=ComponentType.BASIC, 

565 status=ComponentStatus.DISCOVERED, 

566 ) 

567 return components 

568 

569 async def scaffold_component( 

570 self, 

571 name: str, 

572 component_type: ComponentType = ComponentType.DATACLASS, 

573 props: dict[str, type] | None = None, 

574 htmx_enabled: bool = False, 

575 endpoint: str = "", 

576 trigger: str = "click", 

577 target: str = "#content", 

578 children: list[str] | None = None, 

579 target_path: AsyncPath | None = None, 

580 ) -> AsyncPath: 

581 """Scaffold a new component.""" 

582 if self.advanced_registry is None: 

583 await self._init_htmy_registry() 

584 

585 if self.advanced_registry is None: 

586 raise ComponentRenderError( 

587 "Advanced registry not available for scaffolding" 

588 ) 

589 

590 kwargs: dict[str, Any] = {} 

591 if props: 

592 kwargs["props"] = props 

593 if htmx_enabled: 

594 kwargs["htmx_enabled"] = True 

595 if endpoint: 

596 kwargs["endpoint"] = endpoint 

597 kwargs["trigger"] = trigger 

598 kwargs["target"] = target 

599 if children: 

600 kwargs["children"] = children 

601 

602 return await self.advanced_registry.scaffold_component( 

603 name, component_type, target_path, **kwargs 

604 ) 

605 

606 async def validate_component(self, component_name: str) -> ComponentMetadata: 

607 """Validate a specific component.""" 

608 components = await self.discover_components() 

609 if component_name not in components: 

610 raise ComponentNotFound(f"Component '{component_name}' not found") 

611 

612 return components[component_name] 

613 

614 def get_lifecycle_manager(self) -> ComponentLifecycleManager | None: 

615 """Get the component lifecycle manager.""" 

616 if self.advanced_registry is not None: 

617 return self.advanced_registry.lifecycle_manager 

618 return None 

619 

620 def register_lifecycle_hook( 

621 self, event: str, callback: t.Callable[..., Any] 

622 ) -> None: 

623 """Register a lifecycle hook.""" 

624 lifecycle_manager = self.get_lifecycle_manager() 

625 if lifecycle_manager is not None: 

626 lifecycle_manager.register_hook(event, callback) 

627 

628 def _create_block_renderer(self, request: t.Any = None) -> t.Callable[..., t.Any]: 

629 async def render_block( 

630 block_name: str, context: dict[str, t.Any] | None = None, **kwargs: t.Any 

631 ) -> str: 

632 if context is None: 

633 context = {} 

634 

635 block_context = context | kwargs 

636 

637 if ( 

638 self.jinja_templates 

639 and hasattr(self.jinja_templates, "app") 

640 and hasattr(self.jinja_templates.app, "render_block") 

641 ): 

642 try: 

643 if asyncio.iscoroutinefunction( 

644 self.jinja_templates.app.render_block 

645 ): 

646 rendered = await self.jinja_templates.app.render_block( 

647 block_name, block_context 

648 ) 

649 else: 

650 rendered = self.jinja_templates.app.render_block( 

651 block_name, block_context 

652 ) 

653 return rendered # type: ignore[no-any-return] 

654 except Exception as e: 

655 debug( 

656 f"Failed to render block '{block_name}' in HTMY component: {e}" 

657 ) 

658 return f"<!-- Error rendering block '{block_name}': {e} -->" 

659 else: 

660 debug( 

661 f"No block renderer available for '{block_name}' in HTMY component" 

662 ) 

663 return f"<!-- No block renderer available for '{block_name}' -->" 

664 

665 return render_block 

666 

667 async def init(self, cache: t.Any | None = None) -> None: 

668 if cache is None: 

669 try: 

670 cache = depends.get("cache") 

671 except Exception: 

672 cache = None 

673 self.cache = cache 

674 try: 

675 self.storage = depends.get("storage") 

676 except Exception: 

677 self.storage = None 

678 await self._init_htmy_registry() 

679 try: 

680 self.jinja_templates = depends.get("templates") 

681 except Exception: 

682 self.jinja_templates = None 

683 depends.set("htmy", self) 

684 debug("HTMY Templates adapter initialized") 

685 

686 async def render_template( 

687 self, 

688 request: t.Any, 

689 template: str, 

690 context: dict[str, t.Any] | None = None, 

691 status_code: int = 200, 

692 headers: dict[str, str] | None = None, 

693 ) -> HTMLResponse: 

694 return await self.render_component( 

695 request=request, 

696 component=template, 

697 context=context, 

698 status_code=status_code, 

699 headers=headers, 

700 ) 

701 

702 

703MODULE_ID = UUID("01937d86-e1f2-7890-abcd-ef1234567890") 

704MODULE_STATUS = AdapterStatus.STABLE if AdapterStatus is not None else None 

705 

706with suppress(Exception): 

707 depends.set(HTMYTemplates)