Coverage for fastblocks/adapters/templates/htmy.py: 0%
289 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -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 depends
18from acb.adapters import import_adapter
20htmy = depends.get("htmy")
22HTMYTemplates = import_adapter("htmy")
24response = await htmy.render_component(request, "my_component", {"data": data})
26component_class = await htmy.get_component_class("my_component")
27```
29Author: lesleslie <les@wedgwoodwebworks.com>
30Created: 2025-01-13
31"""
33import asyncio
34import typing as t
35from contextlib import suppress
36from uuid import UUID
38from acb.adapters import AdapterStatus, get_adapter, import_adapter, root_path
39from acb.debug import debug
40from acb.depends import depends
41from anyio import Path as AsyncPath
42from starlette.responses import HTMLResponse
44from ._base import TemplatesBase, TemplatesBaseSettings
46try:
47 from fastblocks.actions.sync.strategies import SyncDirection, SyncStrategy
48 from fastblocks.actions.sync.templates import sync_templates
49except ImportError:
50 sync_templates = None
51 SyncDirection = None
52 SyncStrategy = None
54try:
55 Cache, Storage, Models = import_adapter()
56except Exception:
57 Cache = Storage = Models = None
60class ComponentNotFound(Exception):
61 pass
64class ComponentCompilationError(Exception):
65 pass
68class HTMYComponentRegistry:
69 def __init__(
70 self,
71 searchpaths: list[AsyncPath] | None = None,
72 cache: t.Any = None,
73 storage: t.Any = None,
74 ) -> None:
75 self.searchpaths = searchpaths or []
76 self.cache = cache
77 self.storage = storage
78 self._component_cache: dict[str, t.Any] = {}
79 self._source_cache: dict[str, str] = {}
81 @staticmethod
82 def get_cache_key(component_path: AsyncPath, cache_type: str = "source") -> str:
83 return f"htmy_component_{cache_type}:{component_path}"
85 @staticmethod
86 def get_storage_path(component_path: AsyncPath) -> AsyncPath:
87 return component_path
89 async def discover_components(self) -> dict[str, AsyncPath]:
90 components = {}
91 for search_path in self.searchpaths:
92 if not await search_path.exists():
93 continue
94 async for component_file in search_path.rglob("*.py"):
95 if component_file.name == "__init__.py":
96 continue
97 component_name = component_file.stem
98 components[component_name] = component_file
100 return components
102 async def _cache_component_source(
103 self, component_path: AsyncPath, source: str
104 ) -> None:
105 if self.cache is not None:
106 cache_key = self.get_cache_key(component_path)
107 await self.cache.set(cache_key, source.encode())
109 async def _cache_component_bytecode(
110 self, component_path: AsyncPath, bytecode: bytes
111 ) -> None:
112 if self.cache is not None:
113 cache_key = self.get_cache_key(component_path, "bytecode")
114 await self.cache.set(cache_key, bytecode)
116 async def _get_cached_source(self, component_path: AsyncPath) -> str | None:
117 if self.cache is not None:
118 cache_key = self.get_cache_key(component_path)
119 cached = await self.cache.get(cache_key)
120 if cached:
121 return cached.decode()
122 return None
124 async def _get_cached_bytecode(self, component_path: AsyncPath) -> bytes | None:
125 if self.cache is not None:
126 cache_key = self.get_cache_key(component_path, "bytecode")
127 return await self.cache.get(cache_key)
128 return None
130 async def _sync_component_file(
131 self,
132 path: AsyncPath,
133 storage_path: AsyncPath,
134 ) -> tuple[str, int]:
135 if sync_templates is None or SyncDirection is None or SyncStrategy is None:
136 return await self._sync_from_storage_fallback(path, storage_path)
138 try:
139 strategy = SyncStrategy(backup_on_conflict=False)
140 component_paths = [path]
141 result = await sync_templates(
142 template_paths=component_paths,
143 strategy=strategy,
144 )
146 source = await path.read_text()
147 local_stat = await path.stat()
148 local_mtime = int(local_stat.st_mtime)
150 debug(f"Component sync result: {result.sync_status} for {path}")
151 return source, local_mtime
153 except Exception as e:
154 debug(f"Sync action failed for {path}: {e}, falling back to primitive sync")
155 return await self._sync_from_storage_fallback(path, storage_path)
157 async def _sync_from_storage_fallback(
158 self,
159 path: AsyncPath,
160 storage_path: AsyncPath,
161 ) -> tuple[str, int]:
162 local_stat = await path.stat()
163 local_mtime = int(local_stat.st_mtime)
165 if self.storage is not None:
166 try:
167 local_size = local_stat.st_size
168 storage_stat = await self.storage.templates.stat(storage_path)
169 storage_mtime = round(storage_stat.get("mtime", 0))
170 storage_size = storage_stat.get("size", 0)
172 if local_mtime < storage_mtime and local_size != storage_size:
173 resp = await self.storage.templates.open(storage_path)
174 await path.write_bytes(resp)
175 source = resp.decode()
176 return source, storage_mtime
177 except Exception as e:
178 debug(f"Storage fallback failed for {path}: {e}")
180 source = await path.read_text()
181 return source, local_mtime
183 async def get_component_source(self, component_name: str) -> tuple[str, AsyncPath]:
184 components = await self.discover_components()
185 if component_name not in components:
186 raise ComponentNotFound(f"Component '{component_name}' not found")
187 component_path = components[component_name]
188 cache_key = str(component_path)
189 if cache_key in self._source_cache:
190 return self._source_cache[cache_key], component_path
191 cached_source = await self._get_cached_source(component_path)
192 if cached_source:
193 self._source_cache[cache_key] = cached_source
194 return cached_source, component_path
195 storage_path = self.get_storage_path(component_path)
196 source, _ = await self._sync_component_file(component_path, storage_path)
197 self._source_cache[cache_key] = source
198 await self._cache_component_source(component_path, source)
200 return source, component_path
202 async def get_component_class(self, component_name: str) -> t.Any:
203 if component_name in self._component_cache:
204 return self._component_cache[component_name]
205 source, component_path = await self.get_component_source(component_name)
206 cached_bytecode = await self._get_cached_bytecode(component_path)
207 try:
208 if cached_bytecode:
209 try:
210 import pickle
212 component_class = pickle.loads(cached_bytecode)
213 self._component_cache[component_name] = component_class
214 return component_class
215 except Exception as e:
216 debug(f"Failed to load cached bytecode for {component_name}: {e}")
217 namespace = {}
218 compiled_code = compile(source, str(component_path), "exec")
219 exec(compiled_code, namespace)
220 component_class = None
221 for obj in namespace.values():
222 if hasattr(obj, "htmy") and callable(getattr(obj, "htmy")):
223 component_class = obj
224 break
225 if component_class is None:
226 raise ComponentCompilationError(
227 f"No valid component class found in {component_path}"
228 )
229 self._component_cache[component_name] = component_class
230 try:
231 import pickle
233 bytecode = pickle.dumps(component_class)
234 await self._cache_component_bytecode(component_path, bytecode)
235 except Exception as e:
236 debug(f"Failed to cache bytecode for {component_name}: {e}")
238 return component_class
239 except Exception as e:
240 raise ComponentCompilationError(
241 f"Failed to compile component '{component_name}': {e}"
242 ) from e
245class HTMYTemplatesSettings(TemplatesBaseSettings):
246 searchpaths: list[str] = []
247 cache_timeout: int = 300
248 enable_bidirectional: bool = True
249 debug_components: bool = False
252class HTMYTemplates(TemplatesBase):
253 def __init__(self, **kwargs: t.Any) -> None:
254 super().__init__(**kwargs)
255 self.htmy_registry: HTMYComponentRegistry | None = None
256 self.component_searchpaths: list[AsyncPath] = []
257 self.jinja_templates: t.Any = None
259 async def get_component_searchpaths(self, app_adapter: t.Any) -> list[AsyncPath]:
260 searchpaths = []
261 if callable(root_path):
262 base_root = AsyncPath(root_path())
263 else:
264 base_root = AsyncPath(root_path)
265 debug(f"get_component_searchpaths: app_adapter={app_adapter}")
266 if app_adapter:
267 category = getattr(app_adapter, "category", "app")
268 debug(f"get_component_searchpaths: using category={category}")
269 template_paths = self.get_searchpath(
270 app_adapter, base_root / "templates" / category
271 )
272 debug(f"get_component_searchpaths: template_paths={template_paths}")
273 for template_path in template_paths:
274 component_path = template_path / "components"
275 searchpaths.append(component_path)
276 debug(
277 f"get_component_searchpaths: added component_path={component_path}"
278 )
279 debug(f"get_component_searchpaths: final searchpaths={searchpaths}")
280 return searchpaths
282 async def _init_htmy_registry(self) -> None:
283 if self.htmy_registry is not None:
284 return
285 app_adapter = get_adapter("app")
286 if app_adapter is None:
287 try:
288 app_adapter = depends.get("app")
289 except Exception:
290 from types import SimpleNamespace
292 app_adapter = SimpleNamespace(name="app", category="app")
293 self.component_searchpaths = await self.get_component_searchpaths(app_adapter)
294 self.htmy_registry = HTMYComponentRegistry(
295 searchpaths=self.component_searchpaths,
296 cache=self.cache,
297 storage=self.storage,
298 )
300 async def clear_component_cache(self, component_name: str | None = None) -> None:
301 if self.htmy_registry is None:
302 return
303 if component_name:
304 self.htmy_registry._component_cache.pop(component_name, None)
305 if self.cache:
306 components = await self.htmy_registry.discover_components()
307 if component_name in components:
308 component_path = components[component_name]
309 source_key = HTMYComponentRegistry.get_cache_key(component_path)
310 bytecode_key = HTMYComponentRegistry.get_cache_key(
311 component_path, "bytecode"
312 )
313 await self.cache.delete(source_key)
314 await self.cache.delete(bytecode_key)
315 debug(f"HTMY component cache cleared for: {component_name}")
316 else:
317 self.htmy_registry._component_cache.clear()
318 self.htmy_registry._source_cache.clear()
319 if self.cache:
320 with suppress(NotImplementedError, AttributeError):
321 await self.cache.clear("htmy_component_source")
322 await self.cache.clear("htmy_component_bytecode")
323 debug("All HTMY component caches cleared")
325 async def get_component_class(self, component_name: str) -> t.Any:
326 if self.htmy_registry is None:
327 await self._init_htmy_registry()
329 return await self.htmy_registry.get_component_class(component_name)
331 async def render_component(
332 self,
333 request: t.Any,
334 component: str,
335 context: dict[str, t.Any] | None = None,
336 status_code: int = 200,
337 headers: dict[str, str] | None = None,
338 **kwargs: t.Any,
339 ) -> HTMLResponse:
340 if context is None:
341 context = {}
342 if headers is None:
343 headers = {}
345 if self.htmy_registry is None:
346 await self._init_htmy_registry()
348 try:
349 component_class = await self.htmy_registry.get_component_class(component)
351 component_instance = component_class(**context, **kwargs)
353 htmy_context = {
354 "request": request,
355 **context,
356 "render_template": self._create_template_renderer(request),
357 "render_block": self._create_block_renderer(request),
358 "_template_system": "htmy",
359 "_request": request,
360 }
362 if asyncio.iscoroutinefunction(component_instance.htmy):
363 rendered_content = await component_instance.htmy(htmy_context)
364 else:
365 rendered_content = component_instance.htmy(htmy_context)
367 html_content = str(rendered_content)
369 return HTMLResponse(
370 content=html_content,
371 status_code=status_code,
372 headers=headers,
373 )
375 except (ComponentNotFound, ComponentCompilationError) as e:
376 return HTMLResponse(
377 content=f"<html><body>Component {component} error: {e}</body></html>",
378 status_code=404,
379 headers=headers,
380 )
382 def _create_template_renderer(
383 self, request: t.Any = None
384 ) -> t.Callable[..., t.Any]:
385 async def render_template(
386 template_name: str,
387 context: dict[str, t.Any] | None = None,
388 inherit_context: bool = True, # noqa: ARG001
389 **kwargs: t.Any,
390 ) -> str:
391 if context is None:
392 context = {}
394 template_context = {**context, **kwargs}
396 if self.jinja_templates and hasattr(self.jinja_templates, "app"):
397 try:
398 template = self.jinja_templates.app.get_template(template_name)
399 if asyncio.iscoroutinefunction(template.render):
400 rendered = await template.render(template_context)
401 else:
402 rendered = template.render(template_context)
403 return rendered
404 except Exception as e:
405 debug(
406 f"Failed to render template '{template_name}' in HTMY component: {e}"
407 )
408 return f"<!-- Error rendering template '{template_name}': {e} -->"
409 else:
410 debug(
411 f"No Jinja2 adapter available to render template '{template_name}' in HTMY component"
412 )
413 return f"<!-- No template renderer available for '{template_name}' -->"
415 return render_template
417 def _create_block_renderer(self, request: t.Any = None) -> t.Callable[..., t.Any]:
418 async def render_block(
419 block_name: str, context: dict[str, t.Any] | None = None, **kwargs: t.Any
420 ) -> str:
421 if context is None:
422 context = {}
424 block_context = {**context, **kwargs}
426 if (
427 self.jinja_templates
428 and hasattr(self.jinja_templates, "app")
429 and hasattr(self.jinja_templates.app, "render_block")
430 ):
431 try:
432 if asyncio.iscoroutinefunction(
433 self.jinja_templates.app.render_block
434 ):
435 rendered = await self.jinja_templates.app.render_block(
436 block_name, block_context
437 )
438 else:
439 rendered = self.jinja_templates.app.render_block(
440 block_name, block_context
441 )
442 return rendered
443 except Exception as e:
444 debug(
445 f"Failed to render block '{block_name}' in HTMY component: {e}"
446 )
447 return f"<!-- Error rendering block '{block_name}': {e} -->"
448 else:
449 debug(
450 f"No block renderer available for '{block_name}' in HTMY component"
451 )
452 return f"<!-- No block renderer available for '{block_name}' -->"
454 return render_block
456 async def init(self, cache: t.Any | None = None) -> None:
457 if cache is None:
458 try:
459 cache = depends.get("cache")
460 except Exception:
461 cache = None
462 self.cache = cache
463 try:
464 self.storage = depends.get("storage")
465 except Exception:
466 self.storage = None
467 await self._init_htmy_registry()
468 try:
469 self.jinja_templates = depends.get("templates")
470 except Exception:
471 self.jinja_templates = None
472 depends.set("htmy", self)
473 debug("HTMY Templates adapter initialized")
475 async def render_template(
476 self,
477 request: t.Any,
478 template: str,
479 context: dict[str, t.Any] | None = None,
480 status_code: int = 200,
481 headers: dict[str, str] | None = None,
482 ) -> HTMLResponse:
483 return await self.render_component(
484 request=request,
485 component=template,
486 context=context,
487 status_code=status_code,
488 headers=headers,
489 )
492MODULE_ID = UUID("01937d86-e1f2-7890-abcd-ef1234567890")
493MODULE_STATUS = AdapterStatus.STABLE
495with suppress(Exception):
496 depends.set(HTMYTemplates)