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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""HTMY Templates Adapter for FastBlocks.
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
11Requirements:
12- htmy>=0.1.0
13- redis>=3.5.3 (for caching)
15Usage:
16```python
17from acb.depends import Inject, depends
19htmy = depends.get("htmy")
21HTMYTemplates = import_adapter("htmy")
23response = await htmy.render_component(request, "my_component", {"data": data})
25component_class = await htmy.get_component_class("my_component")
26```
28Author: lesleslie <les@wedgwoodwebworks.com>
29Created: 2025-01-13
30"""
32import asyncio
33import typing as t
34from contextlib import suppress
35from enum import Enum
36from typing import TYPE_CHECKING, Any
37from uuid import UUID
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
48 imports_successful = True
49except ImportError:
50 _AdapterStatus = None
51 _get_adapter = None
52 _import_adapter = None
53 _root_path = None
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"
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
79from ._base import TemplatesBase, TemplatesBaseSettings
80from ._htmy_components import (
81 AdvancedHTMYComponentRegistry,
82 ComponentLifecycleManager,
83 ComponentMetadata,
84 ComponentRenderError,
85 ComponentStatus,
86 ComponentType,
87)
89if TYPE_CHECKING:
90 from fastblocks.actions.sync.strategies import SyncDirection, SyncStrategy
91 from fastblocks.actions.sync.templates import sync_templates
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]
101try:
102 Cache, Storage, Models = import_adapter()
103except Exception:
104 Cache = Storage = Models = None
107class ComponentNotFound(Exception):
108 pass
111class ComponentCompilationError(Exception):
112 pass
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] = {}
128 @staticmethod
129 def get_cache_key(component_path: AsyncPath, cache_type: str = "source") -> str:
130 return f"htmy_component_{cache_type}:{component_path}"
132 @staticmethod
133 def get_storage_path(component_path: AsyncPath) -> AsyncPath:
134 return component_path
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
147 return components
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())
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)
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
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
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)
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 )
194 source = await path.read_text()
195 local_stat = await path.stat()
196 local_mtime = int(local_stat.st_mtime)
198 debug(f"Component sync result: {result.sync_status} for {path}")
199 return source, local_mtime
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)
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)
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)
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}")
228 source = await path.read_text()
229 return source, local_mtime
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)
248 return source, component_path
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
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}")
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
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
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)
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
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
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
336 app_adapter = SimpleNamespace(name="app", category="app")
338 self.component_searchpaths = await self.get_component_searchpaths(app_adapter)
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 )
348 # Configure hot reload
349 if self.settings.enable_hot_reload:
350 self.advanced_registry.enable_hot_reload()
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 )
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")
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()
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 )
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 = {}
409 if self.advanced_registry is None:
410 await self._init_htmy_registry()
412 if self.advanced_registry is None:
413 raise ComponentRenderError(
414 f"Advanced registry not initialized for '{component}'"
415 )
417 try:
418 # Add kwargs to context
419 enhanced_context = context | kwargs
421 rendered_content = (
422 await self.advanced_registry.render_component_with_lifecycle(
423 component, enhanced_context, request
424 )
425 )
427 return HTMLResponse(
428 content=rendered_content,
429 status_code=status_code,
430 headers=headers,
431 )
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
440 error_content = f"<html><body><h3>Component {component} error:</h3><pre>{traceback.format_exc()}</pre></body></html>"
442 return HTMLResponse(
443 content=error_content,
444 status_code=500,
445 headers=headers,
446 )
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 = {}
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 )
471 if self.htmy_registry is None:
472 await self._init_htmy_registry()
474 if self.htmy_registry is None:
475 raise ComponentNotFound(
476 f"Component registry not initialized for '{component}'"
477 )
479 try:
480 component_class = await self.htmy_registry.get_component_class(component)
482 component_instance = component_class(**context, **kwargs)
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 }
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)
498 html_content = str(rendered_content)
500 return HTMLResponse(
501 content=html_content,
502 status_code=status_code,
503 headers=headers,
504 )
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 )
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 = {}
525 template_context = context | kwargs
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}' -->"
546 return render_template
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()
553 if self.advanced_registry is not None:
554 return await self.advanced_registry.discover_components()
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
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()
585 if self.advanced_registry is None:
586 raise ComponentRenderError(
587 "Advanced registry not available for scaffolding"
588 )
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
602 return await self.advanced_registry.scaffold_component(
603 name, component_type, target_path, **kwargs
604 )
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")
612 return components[component_name]
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
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)
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 = {}
635 block_context = context | kwargs
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}' -->"
665 return render_block
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")
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 )
703MODULE_ID = UUID("01937d86-e1f2-7890-abcd-ef1234567890")
704MODULE_STATUS = AdapterStatus.STABLE if AdapterStatus is not None else None
706with suppress(Exception):
707 depends.set(HTMYTemplates)