Coverage for fastblocks/actions/gather/templates.py: 44%
265 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"""Template component gathering to consolidate loader, extension, processor, and filter collection."""
3import typing as t
4from contextlib import suppress
5from importlib import import_module
6from inspect import isclass
8from acb.debug import debug
9from anyio import Path as AsyncPath
10from jinja2.ext import Extension
12from .strategies import GatherStrategy, gather_with_strategy
15class TemplateGatherResult:
16 def __init__(
17 self,
18 *,
19 loaders: list[t.Any] | None = None,
20 extensions: list[t.Any] | None = None,
21 context_processors: list[t.Callable[..., t.Any]] | None = None,
22 filters: dict[str, t.Callable[..., t.Any]] | None = None,
23 globals: dict[str, t.Any] | None = None,
24 errors: list[Exception] | None = None,
25 ) -> None:
26 self.loaders = loaders if loaders is not None else []
27 self.extensions = extensions if extensions is not None else []
28 self.context_processors = (
29 context_processors if context_processors is not None else []
30 )
31 self.filters = filters if filters is not None else {}
32 self.globals = globals if globals is not None else {}
33 self.errors = errors if errors is not None else []
35 @property
36 def total_components(self) -> int:
37 return (
38 len(self.loaders)
39 + len(self.extensions)
40 + len(self.context_processors)
41 + len(self.filters)
42 + len(self.globals)
43 )
45 @property
46 def has_errors(self) -> bool:
47 return len(self.errors) > 0
50async def gather_templates(
51 *,
52 template_paths: list[AsyncPath] | None = None,
53 loader_types: list[str] | None = None,
54 extension_modules: list[str] | None = None,
55 context_processor_paths: list[str] | None = None,
56 filter_modules: list[str] | None = None,
57 admin_mode: bool = False,
58 strategy: GatherStrategy | None = None,
59) -> TemplateGatherResult:
60 config = _prepare_template_gather_config(
61 template_paths,
62 loader_types,
63 extension_modules,
64 context_processor_paths,
65 filter_modules,
66 admin_mode,
67 strategy,
68 )
69 result = TemplateGatherResult()
71 tasks = _build_template_gather_tasks(config)
73 gather_result = await gather_with_strategy(
74 tasks,
75 config["strategy"],
76 cache_key=f"templates:{admin_mode}:{':'.join(config['loader_types'])}",
77 )
79 _process_template_gather_results(gather_result, result)
81 result.errors.extend(gather_result.errors)
82 debug(f"Gathered {result.total_components} template components")
84 return result
87def _prepare_template_gather_config(
88 template_paths: list[AsyncPath] | None,
89 loader_types: list[str] | None,
90 extension_modules: list[str] | None,
91 context_processor_paths: list[str] | None,
92 filter_modules: list[str] | None,
93 admin_mode: bool,
94 strategy: GatherStrategy | None,
95) -> dict[str, t.Any]:
96 if loader_types is None:
97 loader_types = ["redis", "storage", "filesystem"]
98 if admin_mode:
99 loader_types.append("package")
101 return {
102 "template_paths": template_paths
103 if template_paths is not None
104 else [AsyncPath("templates")],
105 "loader_types": loader_types,
106 "extension_modules": extension_modules,
107 "context_processor_paths": context_processor_paths,
108 "filter_modules": filter_modules,
109 "admin_mode": admin_mode,
110 "strategy": strategy or GatherStrategy(),
111 }
114def _build_template_gather_tasks(
115 config: dict[str, t.Any],
116) -> list[t.Coroutine[t.Any, t.Any, t.Any]]:
117 tasks = []
119 tasks.append(
120 _gather_loaders(
121 config["template_paths"],
122 config["loader_types"],
123 config["admin_mode"],
124 ),
125 )
127 if config["extension_modules"]:
128 tasks.append(_gather_extensions(config["extension_modules"]))
129 else:
130 tasks.append(_gather_default_extensions())
132 if config["context_processor_paths"]:
133 tasks.append(_gather_context_processors(config["context_processor_paths"]))
134 else:
135 tasks.append(_gather_default_context_processors())
137 if config["filter_modules"]:
138 tasks.append(_gather_filters(config["filter_modules"]))
139 else:
140 tasks.append(_gather_default_filters())
142 tasks.append(_gather_template_globals())
144 return tasks
147def _process_template_gather_results(
148 gather_result: t.Any,
149 result: TemplateGatherResult,
150) -> None:
151 component_mapping = [
152 "loaders",
153 "extensions",
154 "context_processors",
155 "filters",
156 "globals",
157 ]
159 for i, success in enumerate(gather_result.success):
160 if i < len(component_mapping):
161 setattr(result, component_mapping[i], success)
164async def _gather_loaders(
165 template_paths: list[AsyncPath],
166 loader_types: list[str],
167 admin_mode: bool,
168) -> list[t.Any]:
169 try:
170 jinja2_module = __import__(
171 "fastblocks.adapters.templates.jinja2",
172 fromlist=[
173 "ChoiceLoader",
174 "FileSystemLoader",
175 "PackageLoader",
176 "RedisLoader",
177 "StorageLoader",
178 ],
179 )
180 jinja2_module.ChoiceLoader
181 FileSystemLoader = jinja2_module.FileSystemLoader
182 PackageLoader = jinja2_module.PackageLoader
183 RedisLoader = jinja2_module.RedisLoader
184 StorageLoader = jinja2_module.StorageLoader
185 except (ImportError, AttributeError) as e:
186 debug(f"Error loading template loader classes: {e}")
187 raise
189 searchpaths: list[AsyncPath] = []
190 for path in template_paths:
191 searchpaths.extend([path, path / "blocks"])
193 loaders = []
195 if "redis" in loader_types:
196 loaders.append(RedisLoader(searchpaths))
198 if "storage" in loader_types:
199 loaders.append(StorageLoader(searchpaths))
201 if "filesystem" in loader_types:
202 loaders.append(FileSystemLoader(searchpaths))
204 if "package" in loader_types and admin_mode:
205 try:
206 from acb.adapters import get_adapter
208 enabled_admin = get_adapter("admin")
209 if enabled_admin:
210 loaders.append(PackageLoader(enabled_admin.name, "templates", "admin"))
211 except Exception as e:
212 debug(f"Could not create package loader: {e}")
214 debug(f"Created {len(loaders)} template loaders")
215 return loaders
218async def _gather_extensions(extension_modules: list[str]) -> list[t.Any]:
219 extensions = []
220 from jinja2.ext import debug as jinja_debug
221 from jinja2.ext import i18n, loopcontrols
223 extensions.extend([loopcontrols, i18n, jinja_debug])
224 for module_path in extension_modules:
225 try:
226 module = import_module(module_path)
227 for attr_name in dir(module):
228 attr = getattr(module, attr_name)
229 if (
230 isclass(attr)
231 and attr.__name__ != "Extension"
232 and issubclass(attr, Extension)
233 ):
234 extensions.append(attr)
235 debug(f"Found extension {attr.__name__} in {module_path}")
236 except Exception as e:
237 debug(f"Error loading extensions from {module_path}: {e}")
238 debug(f"Gathered {len(extensions)} Jinja2 extensions")
239 return extensions
242async def _gather_default_extensions() -> list[t.Any]:
243 from jinja2.ext import debug as jinja_debug
244 from jinja2.ext import i18n, loopcontrols
246 extensions = [loopcontrols, i18n, jinja_debug]
247 await _load_config_extensions(extensions)
249 return extensions
252async def _load_config_extensions(extensions: list[t.Any]) -> None:
253 with suppress(Exception):
254 from acb.depends import depends
256 config = depends.get("config")
257 if _has_template_extensions_config(config):
258 _process_extension_paths(config.templates.extensions, extensions)
261def _has_template_extensions_config(config: t.Any) -> bool:
262 return hasattr(config, "templates") and hasattr(config.templates, "extensions")
265def _process_extension_paths(ext_paths: list[str], extensions: list[t.Any]) -> None:
266 for ext_path in ext_paths:
267 try:
268 module = import_module(ext_path)
269 _extract_extension_classes_from_module(module, extensions)
270 except Exception as e:
271 debug(f"Error loading extension {ext_path}: {e}")
274def _extract_extension_classes_from_module(
275 module: t.Any,
276 extensions: list[t.Any],
277) -> None:
278 for attr_name in dir(module):
279 attr = getattr(module, attr_name)
280 if _is_valid_extension_class(attr):
281 extensions.append(attr)
284def _is_valid_extension_class(attr: t.Any) -> bool:
285 return (
286 isclass(attr) and attr.__name__ != "Extension" and issubclass(attr, Extension)
287 )
290async def _gather_context_processors(
291 processor_paths: list[str],
292) -> list[t.Callable[..., t.Any]]:
293 processors = []
294 for processor_path in processor_paths:
295 try:
296 module_path, func_name = processor_path.rsplit(".", 1)
297 module = import_module(module_path)
298 processor = getattr(module, func_name)
299 if callable(processor):
300 processors.append(processor)
301 debug(f"Found context processor {func_name} in {module_path}")
302 else:
303 debug(f"Context processor {func_name} is not callable")
304 except Exception as e:
305 debug(f"Error loading context processor {processor_path}: {e}")
306 debug(f"Gathered {len(processors)} context processors")
307 return processors
310async def _gather_default_context_processors() -> list[t.Callable[..., t.Any]]:
311 processors = []
312 with suppress(Exception):
313 from acb.depends import depends
315 config = depends.get("config")
316 if hasattr(config, "templates") and hasattr(
317 config.templates,
318 "context_processors",
319 ):
320 for processor_path in config.templates.context_processors:
321 try:
322 module_path, func_name = processor_path.rsplit(".", 1)
323 module = import_module(module_path)
324 processor = getattr(module, func_name)
325 if callable(processor):
326 processors.append(processor)
327 except Exception as e:
328 debug(f"Error loading context processor {processor_path}: {e}")
330 return processors
333async def _gather_filters(
334 filter_modules: list[str],
335) -> dict[str, t.Callable[..., t.Any]]:
336 filters = {}
337 for module_path in filter_modules:
338 try:
339 module = import_module(module_path)
340 _extract_filters_from_module(module, module_path, filters)
341 except Exception as e:
342 debug(f"Error loading filters from {module_path}: {e}")
343 debug(f"Gathered {len(filters)} template filters")
344 return filters
347def _extract_filters_from_module(
348 module: t.Any,
349 module_path: str,
350 filters: dict[str, t.Callable[..., t.Any]],
351) -> None:
352 if hasattr(module, "Filters"):
353 filters_class = module.Filters
354 _extract_filters_from_class(filters_class, module_path, filters)
356 _extract_filter_functions(module, module_path, filters)
359def _extract_filters_from_class(
360 filters_class: t.Any,
361 module_path: str,
362 filters: dict[str, t.Callable[..., t.Any]],
363) -> None:
364 for attr_name in dir(filters_class):
365 if not attr_name.startswith("_"):
366 attr = getattr(filters_class, attr_name)
367 if callable(attr):
368 filters[attr_name] = attr
369 debug(f"Found filter {attr_name} in {module_path}")
372def _extract_filter_functions(
373 module: t.Any,
374 module_path: str,
375 filters: dict[str, t.Callable[..., t.Any]],
376) -> None:
377 for attr_name in dir(module):
378 if attr_name.endswith("_filter") and not attr_name.startswith("_"):
379 attr = getattr(module, attr_name)
380 if callable(attr):
381 filter_name = attr_name.removesuffix("_filter")
382 filters[filter_name] = attr
383 debug(f"Found filter function {filter_name} in {module_path}")
386async def _gather_default_filters() -> dict[str, t.Callable[..., t.Any]]:
387 filters = {}
388 try:
389 filters_module = __import__(
390 "fastblocks.adapters.templates._filters",
391 fromlist=["Filters"],
392 )
393 Filters = filters_module.Filters
394 for attr_name in dir(Filters):
395 if not attr_name.startswith("_") and attr_name != "register_filters":
396 attr = getattr(Filters, attr_name)
397 if callable(attr):
398 filters[attr_name] = attr
399 except (ImportError, AttributeError) as e:
400 debug(f"Error loading default filters: {e}")
402 return filters
405async def _gather_template_globals() -> dict[str, t.Any]:
406 globals_dict = {}
407 try:
408 from acb.depends import depends
410 config = depends.get("config")
411 globals_dict["config"] = config
412 try:
413 models = depends.get("models")
414 globals_dict["models"] = models
415 except Exception:
416 globals_dict["models"] = None
417 if hasattr(config, "templates") and hasattr(config.templates, "globals"):
418 globals_dict.update(config.templates.globals)
419 except Exception as e:
420 debug(f"Error gathering template globals: {e}")
422 return globals_dict
425async def create_choice_loader(
426 loaders: list[t.Any],
427 config: t.Any | None = None,
428) -> t.Any:
429 try:
430 jinja2_module = __import__(
431 "fastblocks.adapters.templates.jinja2",
432 fromlist=["ChoiceLoader"],
433 )
434 ChoiceLoader = jinja2_module.ChoiceLoader
435 except (ImportError, AttributeError) as e:
436 debug(f"Error loading ChoiceLoader: {e}")
437 raise
439 ordered_loaders = []
441 if config and not getattr(config, "deployed", False):
442 filesystem_loaders = [
443 loader for loader in loaders if "FileSystem" in str(type(loader))
444 ]
445 other_loaders = [
446 loader for loader in loaders if "FileSystem" not in str(type(loader))
447 ]
448 ordered_loaders = filesystem_loaders + other_loaders
449 else:
450 cache_loaders = [
451 loader
452 for loader in loaders
453 if any(x in str(type(loader)) for x in ("Redis", "Storage"))
454 ]
455 other_loaders = [
456 loader
457 for loader in loaders
458 if not any(x in str(type(loader)) for x in ("Redis", "Storage"))
459 ]
460 ordered_loaders = cache_loaders + other_loaders
462 debug(f"Created ChoiceLoader with {len(ordered_loaders)} loaders")
463 return ChoiceLoader(ordered_loaders)
466async def create_template_environment(
467 gather_result: TemplateGatherResult,
468 cache: t.Any | None = None,
469) -> t.Any:
470 from jinja2_async_environment.bccache import AsyncRedisBytecodeCache
471 from starlette_async_jinja import AsyncJinja2Templates
473 bytecode_cache = AsyncRedisBytecodeCache(prefix="bccache", client=cache)
475 choice_loader = await create_choice_loader(gather_result.loaders)
477 templates = AsyncJinja2Templates(
478 directory=AsyncPath("templates"),
479 context_processors=gather_result.context_processors,
480 extensions=gather_result.extensions,
481 bytecode_cache=bytecode_cache,
482 enable_async=True,
483 )
485 if choice_loader:
486 templates.env.loader = choice_loader
488 for name, value in gather_result.globals.items():
489 templates.env.globals[name] = value
491 for name, filter_func in gather_result.filters.items():
492 templates.env.filters[name] = filter_func
494 templates.env.block_start_string = "[%"
495 templates.env.block_end_string = "%]"
496 templates.env.variable_start_string = "[["
497 templates.env.variable_end_string = "]]"
498 templates.env.comment_start_string = "[#"
499 templates.env.comment_end_string = "#]"
501 debug("Created Jinja2 environment with gathered components")
502 return templates
505def register_template_filters(
506 templates: t.Any,
507 filters: dict[str, t.Callable[..., t.Any]],
508) -> None:
509 for name, filter_func in filters.items():
510 if hasattr(templates, "filter"):
511 templates.filter(name)(filter_func)
512 else:
513 templates.env.filters[name] = filter_func
515 debug(f"Registered {len(filters)} template filters")