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

1"""HTMY Component Management System for FastBlocks. 

2 

3Provides advanced component discovery, validation, scaffolding, and lifecycle management 

4for HTMY components with deep HTMX integration and async rendering capabilities. 

5 

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 

14 

15Author: lesleslie <les@wedgwoodwebworks.com> 

16Created: 2025-01-13 

17""" 

18 

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 

28 

29from acb.debug import debug 

30from anyio import Path as AsyncPath 

31from starlette.requests import Request 

32 

33try: 

34 from pydantic import BaseModel, Field, ValidationError, validator 

35except ImportError: 

36 BaseModel = None 

37 Field = None 

38 ValidationError = None 

39 validator = None 

40 

41 

42class ComponentStatus(str, Enum): 

43 """Component lifecycle status.""" 

44 

45 DISCOVERED = "discovered" 

46 VALIDATED = "validated" 

47 COMPILED = "compiled" 

48 READY = "ready" 

49 ERROR = "error" 

50 DEPRECATED = "deprecated" 

51 

52 

53class ComponentType(str, Enum): 

54 """Component classification types.""" 

55 

56 BASIC = "basic" 

57 DATACLASS = "dataclass" 

58 PYDANTIC = "pydantic" 

59 ASYNC = "async" 

60 HTMX = "htmx" 

61 COMPOSITE = "composite" 

62 

63 

64@dataclass 

65class ComponentMetadata: 

66 """Component metadata for discovery and management.""" 

67 

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 

78 

79 def __post_init__(self) -> None: 

80 if self.cache_key is None: 

81 self.cache_key = f"component_{self.name}_{self.path.stem}" 

82 

83 

84class ComponentValidationError(Exception): 

85 """Raised when component validation fails.""" 

86 

87 pass 

88 

89 

90class ComponentCompilationError(Exception): 

91 """Raised when component compilation fails.""" 

92 

93 pass 

94 

95 

96class ComponentRenderError(Exception): 

97 """Raised when component rendering fails.""" 

98 

99 pass 

100 

101 

102class HTMXComponentMixin: 

103 """Mixin for HTMX-aware components.""" 

104 

105 @property 

106 def htmx_attrs(self) -> dict[str, str]: 

107 """Default HTMX attributes for the component.""" 

108 return {} 

109 

110 def get_htmx_trigger(self, request: Request) -> str | None: 

111 """Extract HTMX trigger from request.""" 

112 return request.headers.get("HX-Trigger") 

113 

114 def get_htmx_target(self, request: Request) -> str | None: 

115 """Extract HTMX target from request.""" 

116 return request.headers.get("HX-Target") 

117 

118 def is_htmx_request(self, request: Request) -> bool: 

119 """Check if request is from HTMX.""" 

120 return request.headers.get("HX-Request") == "true" 

121 

122 

123class ComponentBase(ABC): 

124 """Base class for all HTMY components.""" 

125 

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 

131 

132 @abstractmethod 

133 def htmy(self, context: dict[str, Any]) -> str: 

134 """Render the component to HTML.""" 

135 pass 

136 

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) 

142 

143 def add_child(self, child: "ComponentBase") -> None: 

144 """Add a child component.""" 

145 child._parent = self 

146 self._children.append(child) 

147 

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) 

153 

154 @property 

155 def children(self) -> list["ComponentBase"]: 

156 """Get child components.""" 

157 return self._children.copy() 

158 

159 @property 

160 def parent(self) -> Optional["ComponentBase"]: 

161 """Get parent component.""" 

162 return self._parent 

163 

164 

165class DataclassComponentBase(ComponentBase): 

166 """Base class for dataclass-based components.""" 

167 

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 ) 

174 

175 def validate_fields(self) -> None: 

176 """Validate component fields.""" 

177 if not is_dataclass(self): 

178 return 

179 

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 ) 

189 

190 

191class ComponentScaffolder: 

192 """Scaffolding system for creating new components.""" 

193 

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

200 

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

207 

208 # Generate component class 

209 mixins = ["HTMXComponentMixin"] if htmx_enabled else [] 

210 base_classes = ["DataclassComponentBase"] + mixins 

211 

212 template = f'''"""Component: {name} 

213 

214Auto-generated component using FastBlocks HTMY scaffolding. 

215""" 

216 

217from dataclasses import dataclass 

218from typing import Any 

219from fastblocks.adapters.templates.htmy_components import ( 

220 DataclassComponentBase, 

221 HTMXComponentMixin, 

222) 

223 

224 

225@dataclass 

226class {name}({", ".join(base_classes)}): 

227 """Auto-generated {name} component.""" 

228{chr(10).join(prop_lines) if prop_lines else " pass"} 

229 

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

240 

241 return template 

242 

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} 

249 

250HTMX-enabled component for interactive behavior. 

251""" 

252 

253from dataclasses import dataclass 

254from typing import Any 

255from fastblocks.adapters.templates.htmy_components import ( 

256 DataclassComponentBase, 

257 HTMXComponentMixin, 

258) 

259 

260 

261@dataclass 

262class {name}(DataclassComponentBase, HTMXComponentMixin): 

263 """HTMX-enabled {name} component.""" 

264 label: str = "{name}" 

265 css_class: str = "{name.lower()}-component" 

266 

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

276 

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

280 

281 return f""" 

282 <div class="{{self.css_class}}" {{attrs}}> 

283 <button type="button">{{self.label}}</button> 

284 </div> 

285 """ 

286''' 

287 

288 return template 

289 

290 @staticmethod 

291 def create_composite_component(name: str, children: list[str]) -> str: 

292 """Create a composite component template.""" 

293 template = f'''"""Component: {name} 

294 

295Composite component containing multiple child components. 

296""" 

297 

298from dataclasses import dataclass 

299from typing import Any 

300from fastblocks.adapters.templates.htmy_components import DataclassComponentBase 

301 

302 

303@dataclass 

304class {name}(DataclassComponentBase): 

305 """Composite {name} component.""" 

306 title: str = "{name}" 

307 

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

312 

313 children_html = "" 

314 if render_component: 

315{chr(10).join([f' children_html += render_component("{child}", context)' for child in children])} 

316 

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

326 

327 return template 

328 

329 

330class ComponentValidator: 

331 """Component validation system.""" 

332 

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

338 

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

344 

345 # Execute and analyze component 

346 namespace: dict[str, Any] = {} 

347 exec(source, namespace) # nosec B102 - trusted component files 

348 

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 

358 

359 if component_class is None: 

360 raise ComponentValidationError( 

361 f"No valid component class found in {component_path}" 

362 ) 

363 

364 # Determine component type 

365 component_type = ComponentValidator._determine_component_type( 

366 component_class 

367 ) 

368 

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 ) 

380 

381 # Extract dependencies and HTMX attributes 

382 if hasattr(component_class, "htmx_attrs"): 

383 metadata.htmx_attributes = getattr(component_class, "htmx_attrs", {}) 

384 

385 return metadata 

386 

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 ) 

395 

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 

403 

404 if BaseModel and issubclass(component_class, BaseModel): 

405 return ComponentType.PYDANTIC 

406 

407 if hasattr(component_class, "async_htmy"): 

408 return ComponentType.ASYNC 

409 

410 return ComponentType.BASIC 

411 

412 

413class ComponentLifecycleManager: 

414 """Manages component lifecycle and state.""" 

415 

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 } 

424 

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) 

429 

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

440 

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 

445 

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 ) 

455 

456 def get_component_state(self, component_id: str) -> dict[str, Any]: 

457 """Get component state.""" 

458 return self._component_states.get(component_id, {}) 

459 

460 def clear_component_state(self, component_id: str) -> None: 

461 """Clear component state.""" 

462 self._component_states.pop(component_id, None) 

463 

464 

465class AdvancedHTMYComponentRegistry: 

466 """Enhanced component registry with advanced features.""" 

467 

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 

483 

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

485 """Discover all components with metadata.""" 

486 components = {} 

487 

488 for search_path in self.searchpaths: 

489 if not await search_path.exists(): 

490 continue 

491 

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

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

494 continue 

495 

496 component_name = component_file.stem 

497 

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 

503 

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 

508 

509 return components 

510 

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 

519 

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] 

524 

525 components = await self.discover_components() 

526 if component_name not in components: 

527 raise ComponentValidationError(f"Component '{component_name}' not found") 

528 

529 metadata = components[component_name] 

530 

531 if metadata.status == ComponentStatus.ERROR: 

532 raise ComponentCompilationError( 

533 f"Component '{component_name}' has errors: {metadata.error_message}" 

534 ) 

535 

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 

541 

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 

551 

552 if component_class is None: 

553 raise ComponentCompilationError( 

554 f"No valid component class found in '{component_name}'" 

555 ) 

556 

557 self._component_cache[component_name] = component_class 

558 metadata.status = ComponentStatus.READY 

559 

560 return component_class 

561 

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 

568 

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]}" 

574 

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 ) 

584 

585 component_class = await self.get_component_class(component_name) 

586 component_instance = component_class(**context) 

587 

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 } 

601 

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) 

609 

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 ) 

618 

619 return str(rendered_content) 

620 

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 

633 

634 def _create_nested_renderer(self, request: Request) -> t.Callable: 

635 """Create a nested component renderer for composition.""" 

636 

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 ) 

645 

646 return render_nested 

647 

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

660 

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) 

668 

669 # Ensure directory exists 

670 await target_path.parent.mkdir(parents=True, exist_ok=True) 

671 

672 # Write component file 

673 await target_path.write_text(content) 

674 

675 # Clear cache to force re-discovery 

676 self.clear_cache() 

677 

678 debug(f"Scaffolded {component_type.value} component '{name}' at {target_path}") 

679 return target_path 

680 

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

689 

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

694 

695 def disable_hot_reload(self) -> None: 

696 """Disable hot reloading.""" 

697 self._hot_reload_enabled = False 

698 debug("HTMY component hot reload disabled") 

699 

700 @property 

701 def lifecycle_manager(self) -> ComponentLifecycleManager: 

702 """Access to lifecycle manager.""" 

703 return self._lifecycle_manager