Coverage for fastblocks/adapters/templates/htmy_components.py: 78%
288 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 00:51 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 00:51 -0700
1"""HTMY Component Management System for FastBlocks.
3Provides advanced component discovery, validation, scaffolding, and lifecycle management
4for HTMY components with deep HTMX integration and async rendering capabilities.
6Key Features:
7- Automatic component discovery with intelligent caching
8- Dataclass-based component scaffolding with validation
9- Component composition and nesting patterns
10- HTMX-aware state management
11- Async component rendering with lifecycle hooks
12- Advanced error handling and debugging
13- Hot-reloading support for development
15Author: lesleslie <les@wedgwoodwebworks.com>
16Created: 2025-01-13
17"""
19import asyncio
20import inspect
21import typing as t
22from abc import ABC, abstractmethod
23from dataclasses import dataclass, field, fields, is_dataclass
24from datetime import datetime
25from enum import Enum
26from typing import Any, Optional
27from uuid import uuid4
29from acb.debug import debug
30from anyio import Path as AsyncPath
31from starlette.requests import Request
33try:
34 from pydantic import BaseModel, Field, ValidationError, validator
35except ImportError:
36 BaseModel = None
37 Field = None
38 ValidationError = None
39 validator = None
42class ComponentStatus(str, Enum):
43 """Component lifecycle status."""
45 DISCOVERED = "discovered"
46 VALIDATED = "validated"
47 COMPILED = "compiled"
48 READY = "ready"
49 ERROR = "error"
50 DEPRECATED = "deprecated"
53class ComponentType(str, Enum):
54 """Component classification types."""
56 BASIC = "basic"
57 DATACLASS = "dataclass"
58 PYDANTIC = "pydantic"
59 ASYNC = "async"
60 HTMX = "htmx"
61 COMPOSITE = "composite"
64@dataclass
65class ComponentMetadata:
66 """Component metadata for discovery and management."""
68 name: str
69 path: AsyncPath
70 type: ComponentType
71 status: ComponentStatus = ComponentStatus.DISCOVERED
72 dependencies: list[str] = field(default_factory=list)
73 htmx_attributes: dict[str, Any] = field(default_factory=dict)
74 cache_key: str | None = None
75 last_modified: datetime | None = None
76 error_message: str | None = None
77 docstring: str | None = None
79 def __post_init__(self) -> None:
80 if self.cache_key is None:
81 self.cache_key = f"component_{self.name}_{self.path.stem}"
84class ComponentValidationError(Exception):
85 """Raised when component validation fails."""
87 pass
90class ComponentCompilationError(Exception):
91 """Raised when component compilation fails."""
93 pass
96class ComponentRenderError(Exception):
97 """Raised when component rendering fails."""
99 pass
102class HTMXComponentMixin:
103 """Mixin for HTMX-aware components."""
105 @property
106 def htmx_attrs(self) -> dict[str, str]:
107 """Default HTMX attributes for the component."""
108 return {}
110 def get_htmx_trigger(self, request: Request) -> str | None:
111 """Extract HTMX trigger from request."""
112 return request.headers.get("HX-Trigger")
114 def get_htmx_target(self, request: Request) -> str | None:
115 """Extract HTMX target from request."""
116 return request.headers.get("HX-Target")
118 def is_htmx_request(self, request: Request) -> bool:
119 """Check if request is from HTMX."""
120 return request.headers.get("HX-Request") == "true"
123class ComponentBase(ABC):
124 """Base class for all HTMY components."""
126 def __init__(self, **kwargs: Any) -> None:
127 self._context: dict[str, Any] = kwargs
128 self._request: Request | None = kwargs.get("request")
129 self._children: list[ComponentBase] = []
130 self._parent: ComponentBase | None = None
132 @abstractmethod
133 def htmy(self, context: dict[str, Any]) -> str:
134 """Render the component to HTML."""
135 pass
137 async def async_htmy(self, context: dict[str, Any]) -> str:
138 """Async version of htmy method."""
139 if asyncio.iscoroutinefunction(self.htmy):
140 return await self.htmy(context)
141 return self.htmy(context)
143 def add_child(self, child: "ComponentBase") -> None:
144 """Add a child component."""
145 child._parent = self
146 self._children.append(child)
148 def remove_child(self, child: "ComponentBase") -> None:
149 """Remove a child component."""
150 if child in self._children:
151 child._parent = None
152 self._children.remove(child)
154 @property
155 def children(self) -> list["ComponentBase"]:
156 """Get child components."""
157 return self._children.copy()
159 @property
160 def parent(self) -> Optional["ComponentBase"]:
161 """Get parent component."""
162 return self._parent
165class DataclassComponentBase(ComponentBase):
166 """Base class for dataclass-based components."""
168 def __init_subclass__(cls, **kwargs: Any) -> None:
169 super().__init_subclass__(**kwargs)
170 if not is_dataclass(cls):
171 raise ComponentValidationError(
172 f"Component {cls.__name__} must be a dataclass"
173 )
175 def validate_fields(self) -> None:
176 """Validate component fields."""
177 if not is_dataclass(self):
178 return
180 for field_info in fields(self):
181 value = getattr(self, field_info.name)
182 if field_info.type and value is not None:
183 # Basic type validation
184 origin = getattr(field_info.type, "__origin__", None)
185 if origin is None and not isinstance(value, field_info.type):
186 raise ComponentValidationError(
187 f"Field {field_info.name} must be of type {field_info.type}"
188 )
191class ComponentScaffolder:
192 """Scaffolding system for creating new components."""
194 @staticmethod
195 def create_basic_component(
196 name: str, props: dict[str, type] | None = None, htmx_enabled: bool = False
197 ) -> str:
198 """Create a basic component template."""
199 props = props or {}
201 # Generate prop fields
202 prop_lines = []
203 init_params = []
204 for prop_name, prop_type in props.items():
205 prop_lines.append(f" {prop_name}: {prop_type.__name__}")
206 init_params.append(f"{prop_name}: {prop_type.__name__}")
208 # Generate component class
209 mixins = ["HTMXComponentMixin"] if htmx_enabled else []
210 base_classes = ["DataclassComponentBase"] + mixins
212 template = f'''"""Component: {name}
214Auto-generated component using FastBlocks HTMY scaffolding.
215"""
217from dataclasses import dataclass
218from typing import Any
219from fastblocks.adapters.templates.htmy_components import (
220 DataclassComponentBase,
221 HTMXComponentMixin,
222)
225@dataclass
226class {name}({", ".join(base_classes)}):
227 """Auto-generated {name} component."""
228{chr(10).join(prop_lines) if prop_lines else " pass"}
230 def htmy(self, context: dict[str, Any]) -> str:
231 """Render the {name} component."""
232 return f"""
233 <div class="{name.lower()}-component">
234 <h3>{name} Component</h3>
235 {f'<p>{{self.{list(props.keys())[0]} if props else "content"}}</p>' if props else ""}
236 <!-- Add your HTML here -->
237 </div>
238 """
239'''
241 return template
243 @staticmethod
244 def create_htmx_component(
245 name: str, endpoint: str, trigger: str = "click", target: str = "#content"
246 ) -> str:
247 """Create an HTMX-enabled component template."""
248 template = f'''"""Component: {name}
250HTMX-enabled component for interactive behavior.
251"""
253from dataclasses import dataclass
254from typing import Any
255from fastblocks.adapters.templates.htmy_components import (
256 DataclassComponentBase,
257 HTMXComponentMixin,
258)
261@dataclass
262class {name}(DataclassComponentBase, HTMXComponentMixin):
263 """HTMX-enabled {name} component."""
264 label: str = "{name}"
265 css_class: str = "{name.lower()}-component"
267 @property
268 def htmx_attrs(self) -> dict[str, str]:
269 """HTMX attributes for the component."""
270 return {{
271 "hx-get": "{endpoint}",
272 "hx-trigger": "{trigger}",
273 "hx-target": "{target}",
274 "hx-swap": "innerHTML"
275 }}
277 def htmy(self, context: dict[str, Any]) -> str:
278 """Render the {name} component."""
279 attrs = " ".join([f'{{k}}="{{v}}"' for k, v in self.htmx_attrs.items()])
281 return f"""
282 <div class="{{self.css_class}}" {{attrs}}>
283 <button type="button">{{self.label}}</button>
284 </div>
285 """
286'''
288 return template
290 @staticmethod
291 def create_composite_component(name: str, children: list[str]) -> str:
292 """Create a composite component template."""
293 template = f'''"""Component: {name}
295Composite component containing multiple child components.
296"""
298from dataclasses import dataclass
299from typing import Any
300from fastblocks.adapters.templates.htmy_components import DataclassComponentBase
303@dataclass
304class {name}(DataclassComponentBase):
305 """Composite {name} component."""
306 title: str = "{name}"
308 def htmy(self, context: dict[str, Any]) -> str:
309 """Render the {name} composite component."""
310 # Access render_component from context if available
311 render_component = context.get("render_component")
313 children_html = ""
314 if render_component:
315{chr(10).join([f' children_html += render_component("{child}", context)' for child in children])}
317 return f"""
318 <div class="{name.lower()}-composite">
319 <h2>{{self.title}}</h2>
320 <div class="children">
321 {{children_html}}
322 </div>
323 </div>
324 """
325'''
327 return template
330class ComponentValidator:
331 """Component validation system."""
333 @staticmethod
334 async def validate_component_file(component_path: AsyncPath) -> ComponentMetadata:
335 """Validate a component file and extract metadata."""
336 try:
337 source = await component_path.read_text()
339 # Basic syntax validation
340 try:
341 compile(source, str(component_path), "exec")
342 except SyntaxError as e:
343 raise ComponentValidationError(f"Syntax error in {component_path}: {e}")
345 # Execute and analyze component
346 namespace: dict[str, Any] = {}
347 exec(source, namespace) # nosec B102 - trusted component files
349 component_class = None
350 for obj in namespace.values():
351 if (
352 inspect.isclass(obj)
353 and hasattr(obj, "htmy")
354 and callable(getattr(obj, "htmy"))
355 ):
356 component_class = obj
357 break
359 if component_class is None:
360 raise ComponentValidationError(
361 f"No valid component class found in {component_path}"
362 )
364 # Determine component type
365 component_type = ComponentValidator._determine_component_type(
366 component_class
367 )
369 # Extract metadata
370 metadata = ComponentMetadata(
371 name=component_path.stem,
372 path=component_path,
373 type=component_type,
374 status=ComponentStatus.VALIDATED,
375 docstring=inspect.getdoc(component_class),
376 last_modified=datetime.fromtimestamp(
377 (await component_path.stat()).st_mtime
378 ),
379 )
381 # Extract dependencies and HTMX attributes
382 if hasattr(component_class, "htmx_attrs"):
383 metadata.htmx_attributes = getattr(component_class, "htmx_attrs", {})
385 return metadata
387 except Exception as e:
388 return ComponentMetadata(
389 name=component_path.stem,
390 path=component_path,
391 type=ComponentType.BASIC,
392 status=ComponentStatus.ERROR,
393 error_message=str(e),
394 )
396 @staticmethod
397 def _determine_component_type(component_class: type) -> ComponentType:
398 """Determine the type of component."""
399 if is_dataclass(component_class):
400 if issubclass(component_class, HTMXComponentMixin):
401 return ComponentType.HTMX
402 return ComponentType.DATACLASS
404 if BaseModel and issubclass(component_class, BaseModel):
405 return ComponentType.PYDANTIC
407 if hasattr(component_class, "async_htmy"):
408 return ComponentType.ASYNC
410 return ComponentType.BASIC
413class ComponentLifecycleManager:
414 """Manages component lifecycle and state."""
416 def __init__(self) -> None:
417 self._component_states: dict[str, dict[str, Any]] = {}
418 self._lifecycle_hooks: dict[str, list[t.Callable]] = {
419 "before_render": [],
420 "after_render": [],
421 "on_error": [],
422 "on_state_change": [],
423 }
425 def register_hook(self, event: str, callback: t.Callable) -> None:
426 """Register a lifecycle hook."""
427 if event in self._lifecycle_hooks:
428 self._lifecycle_hooks[event].append(callback)
430 async def execute_hooks(self, event: str, **kwargs: Any) -> None:
431 """Execute lifecycle hooks for an event."""
432 for hook in self._lifecycle_hooks.get(event, []):
433 try:
434 if asyncio.iscoroutinefunction(hook):
435 await hook(**kwargs)
436 else:
437 hook(**kwargs)
438 except Exception as e:
439 debug(f"Lifecycle hook error for {event}: {e}")
441 def set_component_state(self, component_id: str, state: dict[str, Any]) -> None:
442 """Set component state."""
443 old_state = self._component_states.get(component_id, {})
444 self._component_states[component_id] = state
446 # Trigger state change hooks
447 asyncio.create_task(
448 self.execute_hooks(
449 "on_state_change",
450 component_id=component_id,
451 old_state=old_state,
452 new_state=state,
453 )
454 )
456 def get_component_state(self, component_id: str) -> dict[str, Any]:
457 """Get component state."""
458 return self._component_states.get(component_id, {})
460 def clear_component_state(self, component_id: str) -> None:
461 """Clear component state."""
462 self._component_states.pop(component_id, None)
465class AdvancedHTMYComponentRegistry:
466 """Enhanced component registry with advanced features."""
468 def __init__(
469 self,
470 searchpaths: list[AsyncPath] | None = None,
471 cache: t.Any = None,
472 storage: t.Any = None,
473 ) -> None:
474 self.searchpaths = searchpaths or []
475 self.cache = cache
476 self.storage = storage
477 self._component_cache: dict[str, t.Any] = {}
478 self._metadata_cache: dict[str, ComponentMetadata] = {}
479 self._scaffolder = ComponentScaffolder()
480 self._validator = ComponentValidator()
481 self._lifecycle_manager = ComponentLifecycleManager()
482 self._hot_reload_enabled = False
484 async def discover_components(self) -> dict[str, ComponentMetadata]:
485 """Discover all components with metadata."""
486 components = {}
488 for search_path in self.searchpaths:
489 if not await search_path.exists():
490 continue
492 async for component_file in search_path.rglob("*.py"):
493 if component_file.name == "__init__.py":
494 continue
496 component_name = component_file.stem
498 # Check cache first
499 cached_metadata = self._metadata_cache.get(component_name)
500 if cached_metadata and await self._is_cache_valid(cached_metadata):
501 components[component_name] = cached_metadata
502 continue
504 # Validate and cache metadata
505 metadata = await self._validator.validate_component_file(component_file)
506 self._metadata_cache[component_name] = metadata
507 components[component_name] = metadata
509 return components
511 async def _is_cache_valid(self, metadata: ComponentMetadata) -> bool:
512 """Check if cached metadata is still valid."""
513 try:
514 current_stat = await metadata.path.stat()
515 current_mtime = datetime.fromtimestamp(current_stat.st_mtime)
516 return metadata.last_modified == current_mtime
517 except Exception:
518 return False
520 async def get_component_class(self, component_name: str) -> t.Any:
521 """Get compiled component class with enhanced error handling."""
522 if component_name in self._component_cache:
523 return self._component_cache[component_name]
525 components = await self.discover_components()
526 if component_name not in components:
527 raise ComponentValidationError(f"Component '{component_name}' not found")
529 metadata = components[component_name]
531 if metadata.status == ComponentStatus.ERROR:
532 raise ComponentCompilationError(
533 f"Component '{component_name}' has errors: {metadata.error_message}"
534 )
536 try:
537 source = await metadata.path.read_text()
538 namespace: dict[str, Any] = {}
539 compiled_code = compile(source, str(metadata.path), "exec")
540 exec(compiled_code, namespace) # nosec B102 - trusted component files
542 component_class = None
543 for obj in namespace.values():
544 if (
545 inspect.isclass(obj)
546 and hasattr(obj, "htmy")
547 and callable(getattr(obj, "htmy"))
548 ):
549 component_class = obj
550 break
552 if component_class is None:
553 raise ComponentCompilationError(
554 f"No valid component class found in '{component_name}'"
555 )
557 self._component_cache[component_name] = component_class
558 metadata.status = ComponentStatus.READY
560 return component_class
562 except Exception as e:
563 metadata.status = ComponentStatus.ERROR
564 metadata.error_message = str(e)
565 raise ComponentCompilationError(
566 f"Failed to compile component '{component_name}': {e}"
567 ) from e
569 async def render_component_with_lifecycle(
570 self, component_name: str, context: dict[str, Any], request: Request
571 ) -> str:
572 """Render component with full lifecycle management."""
573 component_id = f"{component_name}_{uuid4().hex[:8]}"
575 try:
576 # Execute before_render hooks
577 await self._lifecycle_manager.execute_hooks(
578 "before_render",
579 component_name=component_name,
580 component_id=component_id,
581 context=context,
582 request=request,
583 )
585 component_class = await self.get_component_class(component_name)
586 component_instance = component_class(**context)
588 # Enhance context with lifecycle and state management
589 enhanced_context = {
590 **context,
591 "request": request,
592 "component_id": component_id,
593 "component_state": self._lifecycle_manager.get_component_state(
594 component_id
595 ),
596 "set_state": lambda state: self._lifecycle_manager.set_component_state(
597 component_id, state
598 ),
599 "render_component": self._create_nested_renderer(request),
600 }
602 # Render component
603 if hasattr(component_instance, "async_htmy"):
604 rendered_content = await component_instance.async_htmy(enhanced_context)
605 elif asyncio.iscoroutinefunction(component_instance.htmy):
606 rendered_content = await component_instance.htmy(enhanced_context)
607 else:
608 rendered_content = component_instance.htmy(enhanced_context)
610 # Execute after_render hooks
611 await self._lifecycle_manager.execute_hooks(
612 "after_render",
613 component_name=component_name,
614 component_id=component_id,
615 rendered_content=rendered_content,
616 request=request,
617 )
619 return str(rendered_content)
621 except Exception as e:
622 # Execute error hooks
623 await self._lifecycle_manager.execute_hooks(
624 "on_error",
625 component_name=component_name,
626 component_id=component_id,
627 error=e,
628 request=request,
629 )
630 raise ComponentRenderError(
631 f"Failed to render component '{component_name}': {e}"
632 ) from e
634 def _create_nested_renderer(self, request: Request) -> t.Callable:
635 """Create a nested component renderer for composition."""
637 async def render_nested(
638 component_name: str, context: dict[str, Any] | None = None
639 ) -> str:
640 if context is None:
641 context = {}
642 return await self.render_component_with_lifecycle(
643 component_name, context, request
644 )
646 return render_nested
648 async def scaffold_component(
649 self,
650 name: str,
651 component_type: ComponentType = ComponentType.DATACLASS,
652 target_path: AsyncPath | None = None,
653 **kwargs: Any,
654 ) -> AsyncPath:
655 """Scaffold a new component with the specified type."""
656 if target_path is None and self.searchpaths:
657 target_path = self.searchpaths[0] / f"{name.lower()}.py"
658 elif target_path is None:
659 raise ValueError("No target path specified and no searchpaths configured")
661 # Generate component code based on type
662 if component_type == ComponentType.HTMX:
663 content = self._scaffolder.create_htmx_component(name, **kwargs)
664 elif component_type == ComponentType.COMPOSITE:
665 content = self._scaffolder.create_composite_component(name, **kwargs)
666 else:
667 content = self._scaffolder.create_basic_component(name, **kwargs)
669 # Ensure directory exists
670 await target_path.parent.mkdir(parents=True, exist_ok=True)
672 # Write component file
673 await target_path.write_text(content)
675 # Clear cache to force re-discovery
676 self.clear_cache()
678 debug(f"Scaffolded {component_type.value} component '{name}' at {target_path}")
679 return target_path
681 def clear_cache(self, component_name: str | None = None) -> None:
682 """Clear component cache."""
683 if component_name:
684 self._component_cache.pop(component_name, None)
685 self._metadata_cache.pop(component_name, None)
686 else:
687 self._component_cache.clear()
688 self._metadata_cache.clear()
690 def enable_hot_reload(self) -> None:
691 """Enable hot reloading for development."""
692 self._hot_reload_enabled = True
693 debug("HTMY component hot reload enabled")
695 def disable_hot_reload(self) -> None:
696 """Disable hot reloading."""
697 self._hot_reload_enabled = False
698 debug("HTMY component hot reload disabled")
700 @property
701 def lifecycle_manager(self) -> ComponentLifecycleManager:
702 """Access to lifecycle manager."""
703 return self._lifecycle_manager