Coverage for fastblocks/adapters/templates/jinja2.py: 47%
605 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"""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 Inject, 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
52# Import event tracking decorator (with fallback if unavailable)
53try:
54 from ._events_wrapper import track_template_render
55except ImportError:
56 # Fallback no-op decorator if events integration unavailable
57 def track_template_render(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
58 return func
61from acb.depends import depends
62from anyio import Path as AsyncPath
63from jinja2 import TemplateNotFound
64from jinja2.ext import Extension, i18n, loopcontrols
65from jinja2.ext import debug as jinja_debug
66from jinja2_async_environment.bccache import AsyncRedisBytecodeCache
67from jinja2_async_environment.loaders import AsyncBaseLoader, SourceType
68from starlette_async_jinja import AsyncJinja2Templates
69from fastblocks.actions.sync.strategies import SyncDirection, SyncStrategy
70from fastblocks.actions.sync.templates import sync_templates
72from ._base import TemplatesBase, TemplatesBaseSettings
74try:
75 Cache, Storage, Models = import_adapter()
76except Exception:
77 Cache = Storage = Models = None
79_TEMPLATE_REPLACEMENTS = [
80 (b"{{", b"[["),
81 (b"}}", b"]]"),
82 (b"{%", b"[%"),
83 (b"%}", b"%]"),
84]
85_HTTP_TO_HTTPS = (b"http://", b"https://")
87_ATTR_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
90def _get_attr_pattern(attr: str) -> re.Pattern[str]:
91 if attr not in _ATTR_PATTERN_CACHE:
92 escaped_attr = re.escape(f"{attr}=")
93 _ATTR_PATTERN_CACHE[attr] = re.compile(
94 escaped_attr
95 ) # REGEX OK: Template attribute pattern compilation for Jinja2
96 return _ATTR_PATTERN_CACHE[attr]
99def _apply_template_replacements(source: bytes, deployed: bool = False) -> bytes:
100 for old_pattern, new_pattern in _TEMPLATE_REPLACEMENTS:
101 source = source.replace(old_pattern, new_pattern)
102 if deployed:
103 source = source.replace(*_HTTP_TO_HTTPS)
105 return source
108class BaseTemplateLoader(AsyncBaseLoader): # type: ignore[misc]
109 config: t.Any = None
110 cache: t.Any = None
111 storage: t.Any = None
113 def __init__(
114 self,
115 searchpath: AsyncPath | t.Sequence[AsyncPath] | None = None,
116 ) -> None:
117 super().__init__(searchpath or [])
118 if self.storage is None:
119 try:
120 self.storage = depends.get("storage")
121 except Exception:
122 self.storage = get_adapter("storage")
123 if self.cache is None:
124 try:
125 self.cache = depends.get("cache")
126 except Exception:
127 self.cache = get_adapter("cache")
128 if not hasattr(self, "config"):
129 try:
130 self.config = depends.get("config")
131 except Exception:
132 config_adapter = get_adapter("config")
133 self.config = (
134 config_adapter if isinstance(config_adapter, Config) else Config()
135 )
137 def get_supported_extensions(self) -> tuple[str, ...]:
138 return ("html", "css", "js")
140 async def _list_templates_for_extensions(
141 self,
142 extensions: tuple[str, ...],
143 ) -> list[str]:
144 found: set[str] = set()
145 for searchpath in self.searchpath:
146 for ext in extensions:
147 async for p in searchpath.rglob(f"*.{ext}"):
148 found.add(str(p))
149 return sorted(found)
151 def _normalize_template(
152 self,
153 environment_or_template: t.Any,
154 template: str | AsyncPath | None = None,
155 ) -> str | AsyncPath:
156 if template is None:
157 template = environment_or_template
158 assert template is not None
159 return template
161 async def _find_template_path_parallel(
162 self,
163 template: str | AsyncPath,
164 ) -> AsyncPath | None:
165 async def check_path(searchpath: AsyncPath) -> AsyncPath | None:
166 path = searchpath / template
167 if await path.is_file():
168 return path
169 return None
171 tasks = [check_path(searchpath) for searchpath in self.searchpath]
172 results = await asyncio.gather(*tasks, return_exceptions=True)
174 for result in results:
175 if isinstance(result, AsyncPath):
176 return result
177 return None
179 async def _find_storage_path_parallel(
180 self,
181 template: str | AsyncPath,
182 ) -> tuple[AsyncPath, AsyncPath] | None:
183 async def check_storage_path(
184 searchpath: AsyncPath,
185 ) -> tuple[AsyncPath, AsyncPath] | None:
186 path = searchpath / template
187 storage_path = Templates.get_storage_path(path)
188 if storage_path and await self.storage.templates.exists(storage_path):
189 return path, storage_path
190 return None
192 tasks = [check_storage_path(searchpath) for searchpath in self.searchpath]
193 results = await asyncio.gather(*tasks, return_exceptions=True)
195 for result in results:
196 if isinstance(result, tuple):
197 return result
198 return None
200 async def _find_cache_path_parallel(
201 self,
202 template: str | AsyncPath,
203 ) -> tuple[AsyncPath, AsyncPath, str] | None:
204 async def check_cache_path(
205 searchpath: AsyncPath,
206 ) -> tuple[AsyncPath, AsyncPath, str] | None:
207 path = searchpath / template if searchpath else AsyncPath(template)
208 storage_path = Templates.get_storage_path(path)
209 cache_key = Templates.get_cache_key(storage_path)
210 if (
211 storage_path
212 and self.cache is not None
213 and await self.cache.exists(cache_key)
214 ):
215 return path, storage_path, cache_key
216 return None
218 tasks = [check_cache_path(searchpath) for searchpath in self.searchpath]
219 results = await asyncio.gather(*tasks, return_exceptions=True)
221 for result in results:
222 if isinstance(result, tuple):
223 return result
224 return None
227class LoaderProtocol(t.Protocol):
228 cache: t.Any
229 config: t.Any
230 storage: t.Any
232 async def get_source_async(
233 self,
234 environment_or_template: t.Any,
235 template: str | AsyncPath | None = None,
236 ) -> tuple[
237 str,
238 str | None,
239 t.Callable[[], bool] | t.Callable[[], t.Awaitable[bool]],
240 ]: ...
242 async def list_templates_async(self) -> list[str]: ...
245class FileSystemLoader(BaseTemplateLoader):
246 async def _check_storage_exists(self, storage_path: AsyncPath) -> bool:
247 if self.storage is not None:
248 return t.cast(bool, await self.storage.templates.exists(storage_path))
249 return False
251 async def _sync_template_file(
252 self,
253 path: AsyncPath,
254 storage_path: AsyncPath,
255 ) -> tuple[bytes, int]:
256 if sync_templates is None or SyncDirection is None or SyncStrategy is None:
257 return await self._sync_from_storage_fallback(path, storage_path)
259 try:
260 strategy = SyncStrategy(
261 backup_on_conflict=False,
262 )
264 template_paths = [path]
265 result = await sync_templates(
266 template_paths=template_paths,
267 strategy=strategy,
268 )
270 resp = await path.read_bytes()
271 local_stat = await path.stat()
272 local_mtime = int(local_stat.st_mtime)
274 debug(f"Template sync result: {result.sync_status} for {path}")
275 return resp, local_mtime
277 except Exception as e:
278 debug(f"Sync action failed for {path}: {e}, falling back to primitive sync")
279 return await self._sync_from_storage_fallback(path, storage_path)
281 async def _sync_from_storage_fallback(
282 self,
283 path: AsyncPath,
284 storage_path: AsyncPath,
285 ) -> tuple[bytes, int]:
286 local_stat = await path.stat()
287 local_mtime = int(local_stat.st_mtime)
288 local_size = local_stat.st_size
289 storage_stat = await self.storage.templates.stat(storage_path)
290 storage_mtime = round(storage_stat.get("mtime"))
291 storage_size = storage_stat.get("size")
293 if local_mtime < storage_mtime and local_size != storage_size:
294 resp = await self.storage.templates.open(storage_path)
295 await path.write_bytes(resp)
296 else:
297 resp = await path.read_bytes()
298 if local_size != storage_size:
299 await self.storage.templates.write(storage_path, resp)
300 return resp, local_mtime
302 async def _read_and_store_template(
303 self,
304 path: AsyncPath,
305 storage_path: AsyncPath,
306 ) -> bytes:
307 try:
308 resp = await path.read_bytes()
309 if self.storage is not None:
310 try:
311 import asyncio
313 await asyncio.wait_for(
314 self.storage.templates.write(storage_path, resp), timeout=5.0
315 )
316 except (TimeoutError, Exception) as e:
317 debug(
318 f"Storage write failed for {storage_path}: {e}, continuing with local file"
319 )
320 return resp
321 except FileNotFoundError:
322 raise TemplateNotFound(path.name)
324 async def _cache_template(self, storage_path: AsyncPath, resp: bytes) -> None:
325 if self.cache is not None:
326 await self.cache.set(Templates.get_cache_key(storage_path), resp)
328 async def get_source_async(
329 self,
330 environment_or_template: t.Any,
331 template: str | AsyncPath | None = None,
332 ) -> SourceType:
333 template = self._normalize_template(environment_or_template, template)
334 path = await self._find_template_path_parallel(template)
335 if path is None:
336 raise TemplateNotFound(str(template))
337 storage_path = Templates.get_storage_path(path)
338 debug(path)
340 fs_exists = await path.exists()
341 storage_exists = await self._check_storage_exists(storage_path)
342 local_mtime = 0
344 if storage_exists and fs_exists and (not self.config.deployed):
345 resp, local_mtime = await self._sync_template_file(path, storage_path)
346 else:
347 resp = await self._read_and_store_template(path, storage_path)
349 await self._cache_template(storage_path, resp)
351 async def uptodate() -> bool:
352 return int((await path.stat()).st_mtime) == local_mtime
354 return (resp.decode(), str(storage_path), uptodate)
356 async def list_templates_async(self) -> list[str]:
357 return await self._list_templates_for_extensions(
358 self.get_supported_extensions(),
359 )
362class StorageLoader(BaseTemplateLoader):
363 async def _check_filesystem_sync_opportunity(
364 self, template: str | AsyncPath, storage_path: AsyncPath
365 ) -> AsyncPath | None:
366 if not self.config or self.config.deployed:
367 return None
369 fs_result = await self._find_template_path_parallel(template)
370 if fs_result and await fs_result.exists():
371 return fs_result
372 return None
374 async def _sync_storage_with_filesystem(
375 self,
376 fs_path: AsyncPath,
377 storage_path: AsyncPath,
378 ) -> tuple[bytes, int]:
379 if sync_templates is None or SyncDirection is None or SyncStrategy is None:
380 resp = await self.storage.templates.open(storage_path)
381 stat = await self.storage.templates.stat(storage_path)
382 return resp, round(stat.get("mtime").timestamp())
384 try:
385 strategy = SyncStrategy(
386 direction=SyncDirection.PULL,
387 backup_on_conflict=False,
388 )
390 result = await sync_templates(
391 template_paths=[fs_path],
392 strategy=strategy,
393 )
395 resp = await fs_path.read_bytes()
396 local_stat = await fs_path.stat()
397 local_mtime = int(local_stat.st_mtime)
399 debug(
400 f"Storage-filesystem sync result: {result.sync_status} for {storage_path}"
401 )
402 return resp, local_mtime
404 except Exception as e:
405 debug(
406 f"Storage sync failed for {storage_path}: {e}, reading from storage only"
407 )
408 resp = await self.storage.templates.open(storage_path)
409 stat = await self.storage.templates.stat(storage_path)
410 return resp, round(stat.get("mtime").timestamp())
412 async def get_source_async(
413 self,
414 environment_or_template: t.Any,
415 template: str | AsyncPath | None = None,
416 ) -> tuple[str, str, t.Callable[[], t.Awaitable[bool]]]:
417 template = self._normalize_template(environment_or_template, template)
418 result = await self._find_storage_path_parallel(template)
419 if result is None:
420 raise TemplateNotFound(str(template))
421 _, storage_path = result
422 debug(storage_path)
424 try:
425 fs_path = await self._check_filesystem_sync_opportunity(
426 template, storage_path
427 )
429 if fs_path:
430 resp, local_mtime = await self._sync_storage_with_filesystem(
431 fs_path, storage_path
432 )
433 else:
434 resp = await self.storage.templates.open(storage_path)
435 local_stat = await self.storage.templates.stat(storage_path)
436 local_mtime = round(local_stat.get("mtime").timestamp())
438 if self.cache is not None:
439 await self.cache.set(Templates.get_cache_key(storage_path), resp)
441 async def uptodate() -> bool:
442 if fs_path and await fs_path.exists():
443 fs_stat = await fs_path.stat()
444 return int(fs_stat.st_mtime) == local_mtime
445 else:
446 storage_stat = await self.storage.templates.stat(storage_path)
447 mtime = storage_stat.get("mtime")
448 if hasattr(mtime, "timestamp"):
449 timestamp = t.cast(float, mtime.timestamp())
450 return round(timestamp) == local_mtime
451 return False
453 return (resp.decode(), str(storage_path), uptodate)
454 except (FileNotFoundError, AttributeError):
455 raise TemplateNotFound(str(template))
457 async def list_templates_async(self) -> list[str]:
458 found: list[str] = []
459 for searchpath in self.searchpath:
460 with suppress(FileNotFoundError):
461 paths = await self.storage.templates.list(
462 Templates.get_storage_path(searchpath),
463 )
464 found.extend(p for p in paths if p.endswith((".html", ".css", ".js")))
465 found.sort()
466 return found
469class RedisLoader(BaseTemplateLoader):
470 async def get_source_async(
471 self,
472 environment_or_template: t.Any,
473 template: str | AsyncPath | None = None,
474 ) -> tuple[str, str | None, t.Callable[[], t.Awaitable[bool]]]:
475 template = self._normalize_template(environment_or_template, template)
476 result = await self._find_cache_path_parallel(template)
477 if result is None:
478 raise TemplateNotFound(str(template))
479 path, _, cache_key = result
480 debug(cache_key)
481 resp = await self.cache.get(cache_key) if self.cache is not None else None
482 if not resp:
483 raise TemplateNotFound(path.name)
485 async def uptodate() -> bool:
486 return True
488 return (resp.decode(), None, uptodate)
490 async def list_templates_async(self) -> list[str]:
491 found: list[str] = []
492 for ext in ("html", "css", "js"):
493 scan_result: t.Any = (
494 await self.cache.scan(f"*.{ext}") if self.cache is not None else []
495 )
496 if hasattr(scan_result, "__aiter__"):
497 async for k in scan_result: # type: ignore
498 found.append(k)
499 else:
500 found.extend(scan_result)
501 found.sort()
502 return found
505class PackageLoader(BaseTemplateLoader):
506 _template_root: AsyncPath
507 _adapter: str
508 package_name: str
509 _loader: t.Any
511 def __init__(
512 self,
513 package_name: str,
514 path: str = "templates",
515 adapter: str = "admin",
516 ) -> None:
517 self.package_path = Path(package_name)
518 self.path = self.package_path / path
519 super().__init__(AsyncPath(self.path))
520 self.package_name = package_name
521 self._adapter = adapter
522 self._template_root = AsyncPath(".")
523 try:
524 if package_name.startswith("/"):
525 spec = None
526 self._loader = None
527 return
528 import_module(package_name)
529 spec = find_spec(package_name)
530 if spec is None:
531 msg = f"Could not find package {package_name}"
532 raise ImportError(msg)
533 except ModuleNotFoundError:
534 spec = None
535 self._loader = None
536 return
537 roots: list[Path] = []
538 template_root = None
539 loader = spec.loader
540 self._loader = loader
541 if spec.submodule_search_locations:
542 roots.extend(Path(s) for s in spec.submodule_search_locations)
543 elif spec.origin is not None:
544 roots.append(Path(spec.origin))
545 for root in roots:
546 root = root / path
547 if root.is_dir():
548 template_root = root
549 break
550 if template_root is None:
551 msg = f"The {package_name!r} package was not installed in a way that PackageLoader understands."
552 raise ValueError(
553 msg,
554 )
555 self._template_root = AsyncPath(template_root)
557 async def get_source_async(
558 self,
559 environment_or_template: t.Any,
560 template: str | AsyncPath | None = None,
561 ) -> tuple[str, str, t.Callable[[], t.Awaitable[bool]]]:
562 if template is None:
563 template = environment_or_template
564 assert template is not None
565 template_path: AsyncPath = AsyncPath(template)
566 path = self._template_root / template_path
567 debug(path)
568 if not await path.is_file():
569 raise TemplateNotFound(template_path.name)
570 source = await path.read_bytes()
571 mtime = (await path.stat()).st_mtime
573 async def uptodate() -> bool:
574 return await path.is_file() and (await path.stat()).st_mtime == mtime
576 source = _apply_template_replacements(source, self.config.deployed)
577 storage_path = Templates.get_storage_path(path)
578 _storage_path: list[str] = list(storage_path.parts)
579 _storage_path[0] = "_templates"
580 _storage_path.insert(1, self._adapter)
581 _storage_path.insert(2, getattr(self.config, self._adapter).style)
582 storage_path = AsyncPath("/".join(_storage_path))
583 cache_key = Templates.get_cache_key(storage_path)
584 if self.cache is not None:
585 await self.cache.set(cache_key, source)
586 return (source.decode(), path.name, uptodate)
588 async def list_templates_async(self) -> list[str]:
589 found: set[str] = set()
590 for ext in ("html", "css", "js"):
591 found.update([str(p) async for p in self._template_root.rglob(f"*.{ext}")])
592 return sorted(found)
595class ChoiceLoader(AsyncBaseLoader): # type: ignore[misc]
596 loaders: list[AsyncBaseLoader | LoaderProtocol]
598 def __init__(
599 self,
600 loaders: list[AsyncBaseLoader | LoaderProtocol],
601 searchpath: AsyncPath | t.Sequence[AsyncPath] | None = None,
602 ) -> None:
603 super().__init__(searchpath or AsyncPath("templates"))
604 self.loaders = loaders
606 async def get_source_async(
607 self,
608 environment_or_template: t.Any,
609 template: str | AsyncPath | None = None,
610 ) -> SourceType:
611 if template is None:
612 template = environment_or_template
613 assert template is not None
614 for loader in self.loaders:
615 try:
616 result = await loader.get_source_async(
617 environment_or_template, template
618 )
619 return result
620 except TemplateNotFound:
621 continue
622 except Exception: # nosec B112
623 continue
624 raise TemplateNotFound(str(template))
626 async def list_templates_async(self) -> list[str]:
627 found: set[str] = set()
628 for loader in self.loaders:
629 templates = await loader.list_templates_async()
630 found.update(templates)
631 return sorted(found)
634class TemplatesSettings(TemplatesBaseSettings):
635 loader: str | None = None
636 extensions: list[str] = []
637 delimiters: dict[str, str] = {
638 "block_start_string": "[%",
639 "block_end_string": "%]",
640 "variable_start_string": "[[",
641 "variable_end_string": "]]",
642 "comment_start_string": "[#",
643 "comment_end_string": "#]",
644 }
645 globals: dict[str, t.Any] = {}
646 context_processors: list[str] = []
648 def __init__(self, **data: t.Any) -> None:
649 from pydantic import BaseModel
651 BaseModel.__init__(self, **data) # type: ignore[arg-type]
652 if not hasattr(self, "cache_timeout"):
653 self.cache_timeout = 300
654 try:
655 models = depends.get("models")
656 self.globals["models"] = models
657 except Exception:
658 self.globals["models"] = None
661class Templates(TemplatesBase):
662 app: AsyncJinja2Templates | None = None
664 def __init__(self, **kwargs: t.Any) -> None:
665 super().__init__(**kwargs)
666 self.filters: dict[str, t.Callable[..., t.Any]] = {}
667 self.enabled_admin = get_adapter("admin")
668 self.enabled_app = self._get_app_adapter()
669 self._admin = None
670 self._admin_initialized = False
672 def _get_app_adapter(self) -> t.Any:
673 app_adapter = get_adapter("app")
674 if app_adapter is not None:
675 return app_adapter
676 with suppress(Exception):
677 app_adapter = depends.get("app")
678 if app_adapter is not None:
679 return app_adapter
681 return None
683 @property
684 def admin(self) -> AsyncJinja2Templates | None:
685 if not self._admin_initialized and self.enabled_admin:
686 import asyncio
688 loop = asyncio.get_event_loop()
689 if (
690 hasattr(self, "_admin_cache")
691 and hasattr(self, "admin_searchpaths")
692 and self.admin_searchpaths is not None
693 ):
694 debug("Initializing admin templates environment")
695 self._admin = loop.run_until_complete(
696 self.init_envs(
697 self.admin_searchpaths,
698 admin=True,
699 cache=self._admin_cache,
700 ),
701 )
702 else:
703 debug(
704 "Skipping admin templates initialization - missing cache or searchpaths"
705 )
706 self._admin_initialized = True
707 return self._admin
709 @admin.setter
710 def admin(self, value: AsyncJinja2Templates | None) -> None:
711 self._admin = value
712 self._admin_initialized = True
714 def get_loader(self, template_paths: list[AsyncPath]) -> ChoiceLoader:
715 searchpaths: list[AsyncPath] = []
716 for path in template_paths:
717 searchpaths.extend([path, path / "blocks"])
718 loaders: list[AsyncBaseLoader] = [
719 RedisLoader(searchpaths),
720 StorageLoader(searchpaths),
721 ]
722 file_loaders: list[AsyncBaseLoader | LoaderProtocol] = [
723 FileSystemLoader(searchpaths),
724 ]
725 jinja_loaders: list[AsyncBaseLoader | LoaderProtocol] = loaders + file_loaders
726 if not self.config.deployed and (not self.config.debug.production):
727 jinja_loaders = file_loaders + loaders
728 if self.enabled_admin and template_paths == self.admin_searchpaths:
729 jinja_loaders.append(
730 PackageLoader(self.enabled_admin.name, "templates", "admin"),
731 )
732 debug(jinja_loaders)
733 return ChoiceLoader(jinja_loaders)
735 async def init_envs(
736 self,
737 template_paths: list[AsyncPath],
738 admin: bool = False,
739 cache: t.Any | None = None,
740 ) -> AsyncJinja2Templates:
741 _extensions: list[t.Any] = [loopcontrols, i18n, jinja_debug]
742 _imported_extensions = [
743 import_module(e) for e in self.config.templates.extensions
744 ]
745 for e in _imported_extensions:
746 _extensions.extend(
747 [
748 v
749 for v in vars(e).values()
750 if isclass(v)
751 and v.__name__ != "Extension"
752 and issubclass(v, Extension)
753 ],
754 )
755 bytecode_cache = AsyncRedisBytecodeCache(prefix="bccache", client=cache)
756 context_processors: list[t.Callable[..., t.Any]] = []
757 for processor_path in self.config.templates.context_processors:
758 module_path, func_name = processor_path.rsplit(".", 1)
759 module = import_module(module_path)
760 processor = getattr(module, func_name)
761 context_processors.append(processor)
762 templates = AsyncJinja2Templates(
763 directory=AsyncPath("templates"),
764 context_processors=context_processors,
765 extensions=_extensions,
766 bytecode_cache=bytecode_cache,
767 enable_async=True,
768 )
769 loader = self.get_loader(template_paths)
770 if loader:
771 templates.env.loader = loader
772 elif self.config.templates.loader:
773 templates.env.loader = literal_eval(self.config.templates.loader)
774 for delimiter, value in self.config.templates.delimiters.items():
775 setattr(templates.env, delimiter, value)
776 templates.env.globals["config"] = self.config # type: ignore[assignment]
777 templates.env.globals["render_block"] = templates.render_block # type: ignore[assignment]
778 templates.env.globals["render_component"] = self._get_htmy_component_renderer() # type: ignore[assignment]
779 if admin:
780 try:
781 from sqladmin.helpers import ( # type: ignore[import-not-found,import-untyped]
782 get_object_identifier,
783 )
784 except ImportError:
785 get_object_identifier = str
786 templates.env.globals["min"] = min # type: ignore[assignment]
787 templates.env.globals["zip"] = zip # type: ignore[assignment]
788 templates.env.globals["admin"] = self # type: ignore[assignment]
789 templates.env.globals["is_list"] = lambda x: isinstance(x, list) # type: ignore[assignment]
790 templates.env.globals["get_object_identifier"] = get_object_identifier # type: ignore[assignment]
791 for k, v in self.config.templates.globals.items():
792 templates.env.globals[k] = v # type: ignore[assignment]
793 return templates
795 def _resolve_cache(self, cache: t.Any | None) -> t.Any | None:
796 if cache is None:
797 try:
798 cache = depends.get("cache")
799 except Exception:
800 cache = None
801 return cache
803 async def _setup_admin_templates(self, cache: t.Any | None) -> None:
804 if self.enabled_admin:
805 self.admin_searchpaths = await self.get_searchpaths(self.enabled_admin)
806 self._admin_cache = cache
808 def _log_loader_info(self) -> None:
809 if self.app and self.app.env.loader and hasattr(self.app.env.loader, "loaders"):
810 for loader in self.app.env.loader.loaders:
811 self.logger.debug(f"{loader.__class__.__name__} initialized")
813 def _log_extension_info(self) -> None:
814 if self.app and hasattr(self.app.env, "extensions"):
815 for ext in self.app.env.extensions:
816 self.logger.debug(f"{ext.split('.')[-1]} loaded")
818 async def _clear_debug_cache(self, cache: t.Any | None) -> None:
819 if getattr(self.config.debug, "templates", False):
820 try:
821 for namespace in (
822 "templates",
823 "_templates",
824 "bccache",
825 "template",
826 "test",
827 ):
828 if cache is not None:
829 await cache.clear(namespace)
830 self.logger.debug("Template caches cleared")
831 with suppress(Exception):
832 htmy_adapter = depends.get("htmy")
833 if htmy_adapter:
834 await htmy_adapter.clear_component_cache()
835 self.logger.debug("HTMY component caches cleared via adapter")
836 except (NotImplementedError, AttributeError) as e:
837 self.logger.debug(f"Cache clear not supported: {e}")
839 def _get_htmy_component_renderer(self) -> t.Callable[..., t.Any]:
840 async def render_component(
841 component_name: str,
842 context: dict[str, t.Any] | None = None,
843 **kwargs: t.Any,
844 ) -> str:
845 try:
846 htmy_adapter = depends.get("htmy")
847 if htmy_adapter:
848 htmy_adapter.jinja_templates = self
850 response = await htmy_adapter.render_component(
851 request=None,
852 component=component_name,
853 context=context,
854 **kwargs,
855 )
856 return (
857 response.body.decode()
858 if hasattr(response.body, "decode")
859 else str(response.body)
860 )
861 else:
862 debug(
863 f"HTMY adapter not available for component '{component_name}'"
864 )
865 return f"<!-- HTMY adapter not available for '{component_name}' -->"
866 except Exception as e:
867 debug(
868 f"Failed to render component '{component_name}' via HTMY adapter: {e}"
869 )
870 return f"<!-- Error rendering component '{component_name}': {e} -->"
872 return render_component
874 async def init(self, cache: t.Any | None = None) -> None:
875 cache = self._resolve_cache(cache)
876 app_adapter = self.enabled_app
877 if app_adapter is None:
878 try:
879 app_adapter = depends.get("app")
880 debug("Retrieved app adapter from dependency injection")
881 except Exception:
882 try:
883 from ..app.default import App
885 app_adapter = depends.get("app") or App()
886 debug("Created app adapter by direct import")
887 depends.set("app", app_adapter)
888 except Exception:
889 from types import SimpleNamespace
891 app_adapter = SimpleNamespace(name="app", category="app")
892 debug(
893 "Created fallback app adapter - ACB discovery failed, direct import failed"
894 )
895 self.app_searchpaths = await self.get_searchpaths(app_adapter)
896 self.app = await self.init_envs(self.app_searchpaths, cache=cache)
897 depends.set("templates", self)
898 self._admin = None
899 self._admin_initialized = False
900 await self._setup_admin_templates(cache)
901 self._log_loader_info()
902 self._log_extension_info()
903 await self._clear_debug_cache(cache)
905 @staticmethod
906 def get_attr(html: str, attr: str) -> str | None:
907 parser = HTMLParser()
908 parser.feed(html)
909 soup = parser.get_starttag_text()
910 if soup is None:
911 return None
912 attr_pattern = _get_attr_pattern(attr)
913 _attr = f"{attr}="
914 for s in soup.split():
915 if attr_pattern.search(s):
916 return s.replace(_attr, "").strip('"')
917 return None
919 def _add_filters(self, env: t.Any) -> None:
920 if hasattr(self, "filters") and self.filters:
921 for name, filter_func in self.filters.items():
922 if hasattr(env, "add_filter"):
923 env.add_filter(filter_func, name)
924 else:
925 env.filters[name] = filter_func
927 @track_template_render
928 async def render_template(
929 self,
930 request: t.Any,
931 template: str,
932 context: dict[str, t.Any] | None = None,
933 status_code: int = 200,
934 headers: dict[str, str] | None = None,
935 ) -> t.Any:
936 if context is None:
937 context = {}
938 if headers is None:
939 headers = {}
941 templates_env = self.app
942 if templates_env:
943 return await templates_env.TemplateResponse(
944 request=request,
945 name=template,
946 context=context,
947 status_code=status_code,
948 headers=headers,
949 )
950 from starlette.responses import HTMLResponse
952 return HTMLResponse(
953 content=f"<html><body>Template {template} not found</body></html>",
954 status_code=404,
955 headers=headers,
956 )
958 @track_template_render
959 async def render_component(
960 self,
961 request: t.Any,
962 component: str,
963 context: dict[str, t.Any] | None = None,
964 status_code: int = 200,
965 headers: dict[str, str] | None = None,
966 **kwargs: t.Any,
967 ) -> t.Any:
968 try:
969 htmy_adapter = depends.get("htmy")
970 if htmy_adapter:
971 htmy_adapter.jinja_templates = self
972 return await htmy_adapter.render_component(
973 request=request,
974 component=component,
975 context=context,
976 status_code=status_code,
977 headers=headers,
978 **kwargs,
979 )
980 else:
981 from starlette.responses import HTMLResponse
983 return HTMLResponse(
984 content=f"<html><body>HTMY adapter not available for component '{component}'</body></html>",
985 status_code=500,
986 headers=headers,
987 )
988 except Exception as e:
989 from starlette.responses import HTMLResponse
991 return HTMLResponse(
992 content=f"<html><body>Component error: {e}</body></html>",
993 status_code=500,
994 headers=headers,
995 )
997 def filter(
998 self,
999 name: str | None = None,
1000 ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]:
1001 def decorator(f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
1002 if self.app and hasattr(self.app.env, "filters"):
1003 self.app.env.filters[name or f.__name__] = f
1004 if self.admin and hasattr(self.admin.env, "filters"):
1005 self.admin.env.filters[name or f.__name__] = f
1006 return f
1008 return decorator
1010 def _load_extensions(self) -> list[t.Any]:
1011 _extensions: list[t.Any] = [loopcontrols, i18n, jinja_debug]
1012 extensions_list = getattr(
1013 getattr(self, "settings", None),
1014 "extensions",
1015 self.config.templates.extensions,
1016 )
1017 _imported_extensions = [import_module(e) for e in extensions_list]
1018 for e in _imported_extensions:
1019 _extensions.extend(
1020 [
1021 v
1022 for v in vars(e).values()
1023 if isclass(v)
1024 and v.__name__ != "Extension"
1025 and issubclass(v, Extension)
1026 ],
1027 )
1028 return _extensions
1031MODULE_ID = UUID("01937d86-4f2a-7b3c-8d9e-1234567890ab")
1032MODULE_STATUS = AdapterStatus.STABLE
1034with suppress(Exception):
1035 depends.set(Templates)