Coverage for fastblocks/adapters/templates/jinja2.py: 0%
596 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"""Jinja2 Templates Adapter for FastBlocks.
3Provides asynchronous Jinja2 template rendering with advanced features including:
4- Multi-layer loader system (Redis cache, cloud storage, filesystem)
5- Bidirectional integration with HTMY components via dedicated HTMY adapter
6- Fragment and partial template support
7- Bytecode caching with Redis backend
8- Template synchronization across cache/storage/filesystem layers
9- Custom delimiters ([[/]] instead of {{/}})
10- Extensive template debugging and error handling
12Requirements:
13- jinja2-async-environment>=0.14.3
14- starlette-async-jinja>=1.12.4
15- jinja2>=3.1.6
16- redis>=3.5.3 (for caching)
18Usage:
19```python
20from acb.depends import depends
21from acb.adapters import import_adapter
23templates = depends.get("templates")
25Templates = import_adapter("templates")
27response = await templates.render_template(
28 request, "index.html", {"title": "FastBlocks"}
29)
30```
32Author: lesleslie <les@wedgwoodwebworks.com>
33Created: 2025-01-12
34"""
36import asyncio
37import re
38import typing as t
39from ast import literal_eval
40from contextlib import suppress
41from html.parser import HTMLParser
42from importlib import import_module
43from importlib.util import find_spec
44from inspect import isclass
45from pathlib import Path
46from uuid import UUID
48from acb.adapters import AdapterStatus, get_adapter, import_adapter
49from acb.config import Config
50from acb.debug import debug
51from acb.depends import depends
52from anyio import Path as AsyncPath
53from jinja2 import TemplateNotFound
54from jinja2.ext import Extension, i18n, loopcontrols
55from jinja2.ext import debug as jinja_debug
56from jinja2_async_environment.bccache import AsyncRedisBytecodeCache
57from jinja2_async_environment.loaders import AsyncBaseLoader, SourceType
58from starlette_async_jinja import AsyncJinja2Templates
60from ._base import TemplatesBase, TemplatesBaseSettings
62try:
63 from fastblocks.actions.sync.strategies import SyncDirection, SyncStrategy
64 from fastblocks.actions.sync.templates import sync_templates
65except ImportError:
66 sync_templates = None
67 SyncDirection = None
68 SyncStrategy = None
70try:
71 Cache, Storage, Models = import_adapter()
72except Exception:
73 Cache = Storage = Models = None
75_TEMPLATE_REPLACEMENTS = [
76 (b"{{", b"[["),
77 (b"}}", b"]]"),
78 (b"{%", b"[%"),
79 (b"%}", b"%]"),
80]
81_HTTP_TO_HTTPS = (b"http://", b"https://")
83_ATTR_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
86def _get_attr_pattern(attr: str) -> re.Pattern[str]:
87 if attr not in _ATTR_PATTERN_CACHE:
88 escaped_attr = re.escape(f"{attr}=")
89 _ATTR_PATTERN_CACHE[attr] = re.compile(
90 escaped_attr
91 ) # REGEX OK: Template attribute pattern compilation for Jinja2
92 return _ATTR_PATTERN_CACHE[attr]
95def _apply_template_replacements(source: bytes, deployed: bool = False) -> bytes:
96 for old_pattern, new_pattern in _TEMPLATE_REPLACEMENTS:
97 source = source.replace(old_pattern, new_pattern)
98 if deployed:
99 source = source.replace(*_HTTP_TO_HTTPS)
101 return source
104class BaseTemplateLoader(AsyncBaseLoader):
105 config: Config = depends()
106 cache: Cache = depends()
107 storage: Storage = depends()
109 def __init__(
110 self,
111 searchpath: AsyncPath | t.Sequence[AsyncPath] | None = None,
112 ) -> None:
113 super().__init__(searchpath or [])
114 if self.storage is None:
115 try:
116 self.storage = depends.get("storage")
117 except Exception:
118 self.storage = get_adapter("storage")
119 if self.cache is None:
120 try:
121 self.cache = depends.get("cache")
122 except Exception:
123 self.cache = get_adapter("cache")
124 if not hasattr(self, "config"):
125 try:
126 self.config = depends.get("config")
127 except Exception:
128 config_adapter = get_adapter("config")
129 self.config = (
130 config_adapter if isinstance(config_adapter, Config) else Config()
131 )
133 def get_supported_extensions(self) -> tuple[str, ...]:
134 return ("html", "css", "js")
136 async def _list_templates_for_extensions(
137 self,
138 extensions: tuple[str, ...],
139 ) -> list[str]:
140 found: set[str] = set()
141 for searchpath in self.searchpath:
142 for ext in extensions:
143 async for p in searchpath.rglob(f"*.{ext}"):
144 found.add(str(p))
145 return sorted(found)
147 def _normalize_template(
148 self,
149 environment_or_template: t.Any,
150 template: str | AsyncPath | None = None,
151 ) -> str | AsyncPath:
152 if template is None:
153 template = environment_or_template
154 assert template is not None
155 return template
157 async def _find_template_path_parallel(
158 self,
159 template: str | AsyncPath,
160 ) -> AsyncPath | None:
161 async def check_path(searchpath: AsyncPath) -> AsyncPath | None:
162 path = searchpath / template
163 if await path.is_file():
164 return path
165 return None
167 tasks = [check_path(searchpath) for searchpath in self.searchpath]
168 results = await asyncio.gather(*tasks, return_exceptions=True)
170 for result in results:
171 if isinstance(result, AsyncPath):
172 return result
173 return None
175 async def _find_storage_path_parallel(
176 self,
177 template: str | AsyncPath,
178 ) -> tuple[AsyncPath, AsyncPath] | None:
179 async def check_storage_path(
180 searchpath: AsyncPath,
181 ) -> tuple[AsyncPath, AsyncPath] | None:
182 path = searchpath / template
183 storage_path = Templates.get_storage_path(path)
184 if storage_path and await self.storage.templates.exists(storage_path):
185 return path, storage_path
186 return None
188 tasks = [check_storage_path(searchpath) for searchpath in self.searchpath]
189 results = await asyncio.gather(*tasks, return_exceptions=True)
191 for result in results:
192 if isinstance(result, tuple):
193 return result
194 return None
196 async def _find_cache_path_parallel(
197 self,
198 template: str | AsyncPath,
199 ) -> tuple[AsyncPath, AsyncPath, str] | None:
200 async def check_cache_path(
201 searchpath: AsyncPath,
202 ) -> tuple[AsyncPath, AsyncPath, str] | None:
203 path = searchpath / template if searchpath else AsyncPath(template)
204 storage_path = Templates.get_storage_path(path)
205 cache_key = Templates.get_cache_key(storage_path)
206 if (
207 storage_path
208 and self.cache is not None
209 and await self.cache.exists(cache_key)
210 ):
211 return path, storage_path, cache_key
212 return None
214 tasks = [check_cache_path(searchpath) for searchpath in self.searchpath]
215 results = await asyncio.gather(*tasks, return_exceptions=True)
217 for result in results:
218 if isinstance(result, tuple):
219 return result
220 return None
223class LoaderProtocol(t.Protocol):
224 cache: t.Any
225 config: t.Any
226 storage: t.Any
228 async def get_source_async(
229 self,
230 environment_or_template: t.Any,
231 template: str | AsyncPath | None = None,
232 ) -> tuple[
233 str,
234 str | None,
235 t.Callable[[], bool] | t.Callable[[], t.Awaitable[bool]],
236 ]: ...
238 async def list_templates_async(self) -> list[str]: ...
241class FileSystemLoader(BaseTemplateLoader):
242 async def _check_storage_exists(self, storage_path: AsyncPath) -> bool:
243 if self.storage is not None:
244 return await self.storage.templates.exists(storage_path)
245 return False
247 async def _sync_template_file(
248 self,
249 path: AsyncPath,
250 storage_path: AsyncPath,
251 ) -> tuple[bytes, int]:
252 if sync_templates is None or SyncDirection is None or SyncStrategy is None:
253 return await self._sync_from_storage_fallback(path, storage_path)
255 try:
256 strategy = SyncStrategy(
257 backup_on_conflict=False,
258 )
260 template_paths = [path]
261 result = await sync_templates(
262 template_paths=template_paths,
263 strategy=strategy,
264 )
266 resp = await path.read_bytes()
267 local_stat = await path.stat()
268 local_mtime = int(local_stat.st_mtime)
270 debug(f"Template sync result: {result.sync_status} for {path}")
271 return resp, local_mtime
273 except Exception as e:
274 debug(f"Sync action failed for {path}: {e}, falling back to primitive sync")
275 return await self._sync_from_storage_fallback(path, storage_path)
277 async def _sync_from_storage_fallback(
278 self,
279 path: AsyncPath,
280 storage_path: AsyncPath,
281 ) -> tuple[bytes, int]:
282 local_stat = await path.stat()
283 local_mtime = int(local_stat.st_mtime)
284 local_size = local_stat.st_size
285 storage_stat = await self.storage.templates.stat(storage_path)
286 storage_mtime = round(storage_stat.get("mtime"))
287 storage_size = storage_stat.get("size")
289 if local_mtime < storage_mtime and local_size != storage_size:
290 resp = await self.storage.templates.open(storage_path)
291 await path.write_bytes(resp)
292 else:
293 resp = await path.read_bytes()
294 if local_size != storage_size:
295 await self.storage.templates.write(storage_path, resp)
296 return resp, local_mtime
298 async def _read_and_store_template(
299 self,
300 path: AsyncPath,
301 storage_path: AsyncPath,
302 ) -> bytes:
303 try:
304 resp = await path.read_bytes()
305 if self.storage is not None:
306 try:
307 import asyncio
309 await asyncio.wait_for(
310 self.storage.templates.write(storage_path, resp), timeout=5.0
311 )
312 except (TimeoutError, Exception) as e:
313 debug(
314 f"Storage write failed for {storage_path}: {e}, continuing with local file"
315 )
316 return resp
317 except FileNotFoundError:
318 raise TemplateNotFound(path.name)
320 async def _cache_template(self, storage_path: AsyncPath, resp: bytes) -> None:
321 if self.cache is not None:
322 await self.cache.set(Templates.get_cache_key(storage_path), resp)
324 async def get_source_async(
325 self,
326 environment_or_template: t.Any,
327 template: str | AsyncPath | None = None,
328 ) -> SourceType:
329 template = self._normalize_template(environment_or_template, template)
330 path = await self._find_template_path_parallel(template)
331 if path is None:
332 raise TemplateNotFound(str(template))
333 storage_path = Templates.get_storage_path(path)
334 debug(path)
336 fs_exists = await path.exists()
337 storage_exists = await self._check_storage_exists(storage_path)
338 local_mtime = 0
340 if storage_exists and fs_exists and (not self.config.deployed):
341 resp, local_mtime = await self._sync_template_file(path, storage_path)
342 else:
343 resp = await self._read_and_store_template(path, storage_path)
345 await self._cache_template(storage_path, resp)
347 async def uptodate() -> bool:
348 return int((await path.stat()).st_mtime) == local_mtime
350 return (resp.decode(), str(storage_path), uptodate)
352 async def list_templates_async(self) -> list[str]:
353 return await self._list_templates_for_extensions(
354 self.get_supported_extensions(),
355 )
358class StorageLoader(BaseTemplateLoader):
359 async def _check_filesystem_sync_opportunity(
360 self, template: str | AsyncPath, storage_path: AsyncPath
361 ) -> AsyncPath | None:
362 if not self.config or self.config.deployed:
363 return None
365 fs_result = await self._find_template_path_parallel(template)
366 if fs_result and await fs_result.exists():
367 return fs_result
368 return None
370 async def _sync_storage_with_filesystem(
371 self,
372 fs_path: AsyncPath,
373 storage_path: AsyncPath,
374 ) -> tuple[bytes, int]:
375 if sync_templates is None or SyncDirection is None or SyncStrategy is None:
376 resp = await self.storage.templates.open(storage_path)
377 stat = await self.storage.templates.stat(storage_path)
378 return resp, round(stat.get("mtime").timestamp())
380 try:
381 strategy = SyncStrategy(
382 direction=SyncDirection.PULL,
383 backup_on_conflict=False,
384 )
386 result = await sync_templates(
387 template_paths=[fs_path],
388 strategy=strategy,
389 )
391 resp = await fs_path.read_bytes()
392 local_stat = await fs_path.stat()
393 local_mtime = int(local_stat.st_mtime)
395 debug(
396 f"Storage-filesystem sync result: {result.sync_status} for {storage_path}"
397 )
398 return resp, local_mtime
400 except Exception as e:
401 debug(
402 f"Storage sync failed for {storage_path}: {e}, reading from storage only"
403 )
404 resp = await self.storage.templates.open(storage_path)
405 stat = await self.storage.templates.stat(storage_path)
406 return resp, round(stat.get("mtime").timestamp())
408 async def get_source_async(
409 self,
410 environment_or_template: t.Any,
411 template: str | AsyncPath | None = None,
412 ) -> tuple[str, str, t.Callable[[], t.Awaitable[bool]]]:
413 template = self._normalize_template(environment_or_template, template)
414 result = await self._find_storage_path_parallel(template)
415 if result is None:
416 raise TemplateNotFound(str(template))
417 _, storage_path = result
418 debug(storage_path)
420 try:
421 fs_path = await self._check_filesystem_sync_opportunity(
422 template, storage_path
423 )
425 if fs_path:
426 resp, local_mtime = await self._sync_storage_with_filesystem(
427 fs_path, storage_path
428 )
429 else:
430 resp = await self.storage.templates.open(storage_path)
431 local_stat = await self.storage.templates.stat(storage_path)
432 local_mtime = round(local_stat.get("mtime").timestamp())
434 if self.cache is not None:
435 await self.cache.set(Templates.get_cache_key(storage_path), resp)
437 async def uptodate() -> bool:
438 if fs_path and await fs_path.exists():
439 fs_stat = await fs_path.stat()
440 return int(fs_stat.st_mtime) == local_mtime
441 else:
442 storage_stat = await self.storage.templates.stat(storage_path)
443 return round(storage_stat.get("mtime").timestamp()) == local_mtime
445 return (resp.decode(), str(storage_path), uptodate)
446 except (FileNotFoundError, AttributeError):
447 raise TemplateNotFound(str(template))
449 async def list_templates_async(self) -> list[str]:
450 found: list[str] = []
451 for searchpath in self.searchpath:
452 with suppress(FileNotFoundError):
453 paths = await self.storage.templates.list(
454 Templates.get_storage_path(searchpath),
455 )
456 found.extend(p for p in paths if p.endswith((".html", ".css", ".js")))
457 found.sort()
458 return found
461class RedisLoader(BaseTemplateLoader):
462 async def get_source_async(
463 self,
464 environment_or_template: t.Any,
465 template: str | AsyncPath | None = None,
466 ) -> tuple[str, str | None, t.Callable[[], t.Awaitable[bool]]]:
467 template = self._normalize_template(environment_or_template, template)
468 result = await self._find_cache_path_parallel(template)
469 if result is None:
470 raise TemplateNotFound(str(template))
471 path, _, cache_key = result
472 debug(cache_key)
473 resp = await self.cache.get(cache_key) if self.cache is not None else None
474 if not resp:
475 raise TemplateNotFound(path.name)
477 async def uptodate() -> bool:
478 return True
480 return (resp.decode(), None, uptodate)
482 async def list_templates_async(self) -> list[str]:
483 found: list[str] = []
484 for ext in ("html", "css", "js"):
485 scan_result = (
486 await self.cache.scan(f"*.{ext}") if self.cache is not None else []
487 )
488 if hasattr(scan_result, "__aiter__"):
489 async for k in scan_result: # type: ignore
490 found.append(k)
491 else:
492 found.extend(scan_result)
493 found.sort()
494 return found
497class PackageLoader(BaseTemplateLoader):
498 _template_root: AsyncPath
499 _adapter: str
500 package_name: str
501 _loader: t.Any
503 def __init__(
504 self,
505 package_name: str,
506 path: str = "templates",
507 adapter: str = "admin",
508 ) -> None:
509 self.package_path = Path(package_name)
510 self.path = self.package_path / path
511 super().__init__(AsyncPath(self.path))
512 self.package_name = package_name
513 self._adapter = adapter
514 self._template_root = AsyncPath(".")
515 try:
516 if package_name.startswith("/"):
517 spec = None
518 self._loader = None
519 return
520 import_module(package_name)
521 spec = find_spec(package_name)
522 if spec is None:
523 msg = f"Could not find package {package_name}"
524 raise ImportError(msg)
525 except ModuleNotFoundError:
526 spec = None
527 self._loader = None
528 return
529 roots: list[Path] = []
530 template_root = None
531 loader = spec.loader
532 self._loader = loader
533 if spec.submodule_search_locations:
534 roots.extend(Path(s) for s in spec.submodule_search_locations)
535 elif spec.origin is not None:
536 roots.append(Path(spec.origin))
537 for root in roots:
538 root = root / path
539 if root.is_dir():
540 template_root = root
541 break
542 if template_root is None:
543 msg = f"The {package_name!r} package was not installed in a way that PackageLoader understands."
544 raise ValueError(
545 msg,
546 )
547 self._template_root = AsyncPath(template_root)
549 async def get_source_async(
550 self,
551 environment_or_template: t.Any,
552 template: str | AsyncPath | None = None,
553 ) -> tuple[str, str, t.Callable[[], t.Awaitable[bool]]]:
554 if template is None:
555 template = environment_or_template
556 assert template is not None
557 template_path: AsyncPath = AsyncPath(template)
558 path = self._template_root / template_path
559 debug(path)
560 if not await path.is_file():
561 raise TemplateNotFound(template_path.name)
562 source = await path.read_bytes()
563 mtime = (await path.stat()).st_mtime
565 async def uptodate() -> bool:
566 return await path.is_file() and (await path.stat()).st_mtime == mtime
568 source = _apply_template_replacements(source, self.config.deployed)
569 storage_path = Templates.get_storage_path(path)
570 _storage_path: list[str] = list(storage_path.parts)
571 _storage_path[0] = "_templates"
572 _storage_path.insert(1, self._adapter)
573 _storage_path.insert(2, getattr(self.config, self._adapter).style)
574 storage_path = AsyncPath("/".join(_storage_path))
575 cache_key = Templates.get_cache_key(storage_path)
576 if self.cache is not None:
577 await self.cache.set(cache_key, source)
578 return (source.decode(), path.name, uptodate)
580 async def list_templates_async(self) -> list[str]:
581 found: set[str] = set()
582 for ext in ("html", "css", "js"):
583 found.update([str(p) async for p in self._template_root.rglob(f"*.{ext}")])
584 return sorted(found)
587class ChoiceLoader(AsyncBaseLoader):
588 loaders: list[AsyncBaseLoader | LoaderProtocol]
590 def __init__(
591 self,
592 loaders: list[AsyncBaseLoader | LoaderProtocol],
593 searchpath: AsyncPath | t.Sequence[AsyncPath] | None = None,
594 ) -> None:
595 super().__init__(searchpath or AsyncPath("templates"))
596 self.loaders = loaders
598 async def get_source_async(
599 self,
600 environment_or_template: t.Any,
601 template: str | AsyncPath | None = None,
602 ) -> SourceType:
603 if template is None:
604 template = environment_or_template
605 assert template is not None
606 for loader in self.loaders:
607 try:
608 result = await loader.get_source_async(
609 environment_or_template, template
610 )
611 return result
612 except TemplateNotFound:
613 continue
614 except Exception: # nosec B112
615 continue
616 raise TemplateNotFound(str(template))
618 async def list_templates_async(self) -> list[str]:
619 found: set[str] = set()
620 for loader in self.loaders:
621 templates = await loader.list_templates_async()
622 found.update(templates)
623 return sorted(found)
626class TemplatesSettings(TemplatesBaseSettings):
627 loader: str | None = None
628 extensions: list[str] = []
629 delimiters: dict[str, str] = {
630 "block_start_string": "[%",
631 "block_end_string": "%]",
632 "variable_start_string": "[[",
633 "variable_end_string": "]]",
634 "comment_start_string": "[#",
635 "comment_end_string": "#]",
636 }
637 globals: dict[str, t.Any] = {}
638 context_processors: list[str] = []
640 def __init__(self, **data: t.Any) -> None:
641 from pydantic import BaseModel
643 BaseModel.__init__(self, **data)
644 if not hasattr(self, "cache_timeout"):
645 self.cache_timeout = 300
646 try:
647 models = depends.get("models")
648 self.globals["models"] = models
649 except Exception:
650 self.globals["models"] = None
653class Templates(TemplatesBase):
654 app: AsyncJinja2Templates | None = None
656 def __init__(self, **kwargs: t.Any) -> None:
657 super().__init__(**kwargs)
658 self.filters: dict[str, t.Callable[..., t.Any]] = {}
659 self.enabled_admin = get_adapter("admin")
660 self.enabled_app = self._get_app_adapter()
661 self._admin = None
662 self._admin_initialized = False
664 def _get_app_adapter(self) -> t.Any:
665 app_adapter = get_adapter("app")
666 if app_adapter is not None:
667 return app_adapter
668 with suppress(Exception):
669 app_adapter = depends.get("app")
670 if app_adapter is not None:
671 return app_adapter
673 return None
675 @property
676 def admin(self) -> AsyncJinja2Templates | None:
677 if not self._admin_initialized and self.enabled_admin:
678 import asyncio
680 loop = asyncio.get_event_loop()
681 if hasattr(self, "_admin_cache") and hasattr(self, "admin_searchpaths"):
682 debug("Initializing admin templates environment")
683 self._admin = loop.run_until_complete(
684 self.init_envs(
685 self.admin_searchpaths,
686 admin=True,
687 cache=self._admin_cache,
688 ),
689 )
690 else:
691 debug(
692 "Skipping admin templates initialization - missing cache or searchpaths"
693 )
694 self._admin_initialized = True
695 return self._admin
697 @admin.setter
698 def admin(self, value: AsyncJinja2Templates | None) -> None:
699 self._admin = value
700 self._admin_initialized = True
702 def get_loader(self, template_paths: list[AsyncPath]) -> ChoiceLoader:
703 searchpaths: list[AsyncPath] = []
704 for path in template_paths:
705 searchpaths.extend([path, path / "blocks"])
706 loaders: list[AsyncBaseLoader] = [
707 RedisLoader(searchpaths),
708 StorageLoader(searchpaths),
709 ]
710 file_loaders: list[AsyncBaseLoader | LoaderProtocol] = [
711 FileSystemLoader(searchpaths),
712 ]
713 jinja_loaders: list[AsyncBaseLoader | LoaderProtocol] = loaders + file_loaders
714 if not self.config.deployed and (not self.config.debug.production):
715 jinja_loaders = file_loaders + loaders
716 if self.enabled_admin and template_paths == self.admin_searchpaths:
717 jinja_loaders.append(
718 PackageLoader(self.enabled_admin.name, "templates", "admin"),
719 )
720 debug(jinja_loaders)
721 return ChoiceLoader(jinja_loaders)
723 @depends.inject
724 async def init_envs(
725 self,
726 template_paths: list[AsyncPath],
727 admin: bool = False,
728 cache: t.Any | None = None,
729 ) -> AsyncJinja2Templates:
730 _extensions: list[t.Any] = [loopcontrols, i18n, jinja_debug]
731 _imported_extensions = [
732 import_module(e) for e in self.config.templates.extensions
733 ]
734 for e in _imported_extensions:
735 _extensions.extend(
736 [
737 v
738 for v in vars(e).values()
739 if isclass(v)
740 and v.__name__ != "Extension"
741 and issubclass(v, Extension)
742 ],
743 )
744 bytecode_cache = AsyncRedisBytecodeCache(prefix="bccache", client=cache)
745 context_processors: list[t.Callable[..., t.Any]] = []
746 for processor_path in self.config.templates.context_processors:
747 module_path, func_name = processor_path.rsplit(".", 1)
748 module = import_module(module_path)
749 processor = getattr(module, func_name)
750 context_processors.append(processor)
751 templates = AsyncJinja2Templates(
752 directory=AsyncPath("templates"),
753 context_processors=context_processors,
754 extensions=_extensions,
755 bytecode_cache=bytecode_cache,
756 enable_async=True,
757 )
758 loader = self.get_loader(template_paths)
759 if loader:
760 templates.env.loader = loader
761 elif self.config.templates.loader:
762 templates.env.loader = literal_eval(self.config.templates.loader)
763 for delimiter, value in self.config.templates.delimiters.items():
764 setattr(templates.env, delimiter, value)
765 templates.env.globals["config"] = self.config # type: ignore[assignment]
766 templates.env.globals["render_block"] = templates.render_block # type: ignore[assignment]
767 templates.env.globals["render_component"] = self._get_htmy_component_renderer() # type: ignore[assignment]
768 if admin:
769 try:
770 from sqladmin.helpers import ( # type: ignore[import-not-found,import-untyped]
771 get_object_identifier,
772 )
773 except ImportError:
774 get_object_identifier = str
775 templates.env.globals["min"] = min # type: ignore[assignment]
776 templates.env.globals["zip"] = zip # type: ignore[assignment]
777 templates.env.globals["admin"] = self # type: ignore[assignment]
778 templates.env.globals["is_list"] = lambda x: isinstance(x, list) # type: ignore[assignment]
779 templates.env.globals["get_object_identifier"] = get_object_identifier # type: ignore[assignment]
780 for k, v in self.config.templates.globals.items():
781 templates.env.globals[k] = v # type: ignore[assignment]
782 return templates
784 def _resolve_cache(self, cache: t.Any | None) -> t.Any | None:
785 if cache is None:
786 try:
787 cache = depends.get("cache")
788 except Exception:
789 cache = None
790 return cache
792 async def _setup_admin_templates(self, cache: t.Any | None) -> None:
793 if self.enabled_admin:
794 self.admin_searchpaths = await self.get_searchpaths(self.enabled_admin)
795 self._admin_cache = cache
797 def _log_loader_info(self) -> None:
798 if self.app and self.app.env.loader and hasattr(self.app.env.loader, "loaders"):
799 for loader in self.app.env.loader.loaders:
800 self.logger.debug(f"{loader.__class__.__name__} initialized")
802 def _log_extension_info(self) -> None:
803 if self.app and hasattr(self.app.env, "extensions"):
804 for ext in self.app.env.extensions:
805 self.logger.debug(f"{ext.split('.')[-1]} loaded")
807 async def _clear_debug_cache(self, cache: t.Any | None) -> None:
808 if getattr(self.config.debug, "templates", False):
809 try:
810 for namespace in (
811 "templates",
812 "_templates",
813 "bccache",
814 "template",
815 "test",
816 ):
817 await cache.clear(namespace)
818 self.logger.debug("Template caches cleared")
819 with suppress(Exception):
820 htmy_adapter = depends.get("htmy")
821 if htmy_adapter:
822 await htmy_adapter.clear_component_cache()
823 self.logger.debug("HTMY component caches cleared via adapter")
824 except (NotImplementedError, AttributeError) as e:
825 self.logger.debug(f"Cache clear not supported: {e}")
827 def _get_htmy_component_renderer(self) -> t.Callable[..., t.Any]:
828 async def render_component(
829 component_name: str,
830 context: dict[str, t.Any] | None = None,
831 **kwargs: t.Any,
832 ) -> str:
833 try:
834 htmy_adapter = depends.get("htmy")
835 if htmy_adapter:
836 htmy_adapter.jinja_templates = self
838 response = await htmy_adapter.render_component(
839 request=None,
840 component=component_name,
841 context=context,
842 **kwargs,
843 )
844 return (
845 response.body.decode()
846 if hasattr(response.body, "decode")
847 else str(response.body)
848 )
849 else:
850 debug(
851 f"HTMY adapter not available for component '{component_name}'"
852 )
853 return f"<!-- HTMY adapter not available for '{component_name}' -->"
854 except Exception as e:
855 debug(
856 f"Failed to render component '{component_name}' via HTMY adapter: {e}"
857 )
858 return f"<!-- Error rendering component '{component_name}': {e} -->"
860 return render_component
862 async def init(self, cache: t.Any | None = None) -> None:
863 cache = self._resolve_cache(cache)
864 app_adapter = self.enabled_app
865 if app_adapter is None:
866 try:
867 app_adapter = depends.get("app")
868 debug("Retrieved app adapter from dependency injection")
869 except Exception:
870 try:
871 from ..app.default import App
873 app_adapter = depends.get("app") or App()
874 debug("Created app adapter by direct import")
875 depends.set("app", app_adapter)
876 except Exception:
877 from types import SimpleNamespace
879 app_adapter = SimpleNamespace(name="app", category="app")
880 debug(
881 "Created fallback app adapter - ACB discovery failed, direct import failed"
882 )
883 self.app_searchpaths = await self.get_searchpaths(app_adapter)
884 self.app = await self.init_envs(self.app_searchpaths, cache=cache)
885 depends.set("templates", self)
886 self._admin = None
887 self._admin_initialized = False
888 await self._setup_admin_templates(cache)
889 self._log_loader_info()
890 self._log_extension_info()
891 await self._clear_debug_cache(cache)
893 @staticmethod
894 def get_attr(html: str, attr: str) -> str | None:
895 parser = HTMLParser()
896 parser.feed(html)
897 soup = parser.get_starttag_text()
898 attr_pattern = _get_attr_pattern(attr)
899 _attr = f"{attr}="
900 for s in soup.split():
901 if attr_pattern.search(s):
902 return s.replace(_attr, "").strip('"')
903 return None
905 def _add_filters(self, env: t.Any) -> None:
906 if hasattr(self, "filters") and self.filters:
907 for name, filter_func in self.filters.items():
908 if hasattr(env, "add_filter"):
909 env.add_filter(filter_func, name)
910 else:
911 env.filters[name] = filter_func
913 async def render_template(
914 self,
915 request: t.Any,
916 template: str,
917 context: dict[str, t.Any] | None = None,
918 status_code: int = 200,
919 headers: dict[str, str] | None = None,
920 ) -> t.Any:
921 if context is None:
922 context = {}
923 if headers is None:
924 headers = {}
926 templates_env = self.app
927 if templates_env:
928 return await templates_env.TemplateResponse(
929 request=request,
930 name=template,
931 context=context,
932 status_code=status_code,
933 headers=headers,
934 )
935 from starlette.responses import HTMLResponse
937 return HTMLResponse(
938 content=f"<html><body>Template {template} not found</body></html>",
939 status_code=404,
940 headers=headers,
941 )
943 async def render_component(
944 self,
945 request: t.Any,
946 component: str,
947 context: dict[str, t.Any] | None = None,
948 status_code: int = 200,
949 headers: dict[str, str] | None = None,
950 **kwargs: t.Any,
951 ) -> t.Any:
952 try:
953 htmy_adapter = depends.get("htmy")
954 if htmy_adapter:
955 htmy_adapter.jinja_templates = self
956 return await htmy_adapter.render_component(
957 request=request,
958 component=component,
959 context=context,
960 status_code=status_code,
961 headers=headers,
962 **kwargs,
963 )
964 else:
965 from starlette.responses import HTMLResponse
967 return HTMLResponse(
968 content=f"<html><body>HTMY adapter not available for component '{component}'</body></html>",
969 status_code=500,
970 headers=headers,
971 )
972 except Exception as e:
973 from starlette.responses import HTMLResponse
975 return HTMLResponse(
976 content=f"<html><body>Component error: {e}</body></html>",
977 status_code=500,
978 headers=headers,
979 )
981 def filter(
982 self,
983 name: str | None = None,
984 ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]:
985 def decorator(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
986 if self.app and hasattr(self.app.env, "filters"):
987 self.app.env.filters[name or f.__name__] = f
988 if self.admin and hasattr(self.admin.env, "filters"):
989 self.admin.env.filters[name or f.__name__] = f
990 return f
992 return decorator
994 def _load_extensions(self) -> list[t.Any]:
995 _extensions: list[t.Any] = [loopcontrols, i18n, jinja_debug]
996 extensions_list = getattr(
997 getattr(self, "settings", None),
998 "extensions",
999 self.config.templates.extensions,
1000 )
1001 _imported_extensions = [import_module(e) for e in extensions_list]
1002 for e in _imported_extensions:
1003 _extensions.extend(
1004 [
1005 v
1006 for v in vars(e).values()
1007 if isclass(v)
1008 and v.__name__ != "Extension"
1009 and issubclass(v, Extension)
1010 ],
1011 )
1012 return _extensions
1015MODULE_ID = UUID("01937d86-4f2a-7b3c-8d9e-1234567890ab")
1016MODULE_STATUS = AdapterStatus.STABLE
1018with suppress(Exception):
1019 depends.set(Templates)