Coverage for fastblocks/actions/gather/templates.py: 45%
261 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"""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) -> list[dict[str, t.Callable[..., t.Any]]]:
336 filters: dict[str, t.Callable[..., t.Any]] = {}
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() -> list[dict[str, t.Callable[..., t.Any]]]:
387 filters: dict[str, t.Callable[..., t.Any]] = {}
388 try:
389 filters_module = __import__(
390 "fastblocks.adapters.templates._filters",
391 fromlist=["Filters"],
392 )
393 filters = getattr(filters_module, "Filters", {})
394 except Exception as e:
395 debug(f"Error loading default filters: {e}")
396 debug(f"Gathered {len(filters)} default template filters")
397 return [filters]
400async def _gather_template_globals() -> list[dict[str, t.Any]]:
401 globals_dict = {}
402 try:
403 from acb.depends import depends
405 config = depends.get("config")
406 globals_dict["config"] = config
407 try:
408 models = depends.get("models")
409 globals_dict["models"] = models
410 except Exception:
411 globals_dict["models"] = None
412 if hasattr(config, "templates") and hasattr(config.templates, "globals"):
413 globals_dict.update(config.templates.globals)
414 except Exception as e:
415 debug(f"Error gathering template globals: {e}")
417 return [globals_dict]
420async def create_choice_loader(
421 loaders: list[t.Any],
422 config: t.Any | None = None,
423) -> t.Any:
424 try:
425 jinja2_module = __import__(
426 "fastblocks.adapters.templates.jinja2",
427 fromlist=["ChoiceLoader"],
428 )
429 ChoiceLoader = jinja2_module.ChoiceLoader
430 except (ImportError, AttributeError) as e:
431 debug(f"Error loading ChoiceLoader: {e}")
432 raise
434 ordered_loaders = []
436 if config and not getattr(config, "deployed", False):
437 filesystem_loaders = [
438 loader for loader in loaders if "FileSystem" in str(type(loader))
439 ]
440 other_loaders = [
441 loader for loader in loaders if "FileSystem" not in str(type(loader))
442 ]
443 ordered_loaders = filesystem_loaders + other_loaders
444 else:
445 cache_loaders = [
446 loader
447 for loader in loaders
448 if any(x in str(type(loader)) for x in ("Redis", "Storage"))
449 ]
450 other_loaders = [
451 loader
452 for loader in loaders
453 if not any(x in str(type(loader)) for x in ("Redis", "Storage"))
454 ]
455 ordered_loaders = cache_loaders + other_loaders
457 debug(f"Created ChoiceLoader with {len(ordered_loaders)} loaders")
458 return ChoiceLoader(ordered_loaders)
461async def create_template_environment(
462 gather_result: TemplateGatherResult,
463 cache: t.Any | None = None,
464) -> t.Any:
465 from jinja2_async_environment.bccache import AsyncRedisBytecodeCache
466 from starlette_async_jinja import AsyncJinja2Templates
468 bytecode_cache = AsyncRedisBytecodeCache(prefix="bccache", client=cache)
470 choice_loader = await create_choice_loader(gather_result.loaders)
472 templates = AsyncJinja2Templates(
473 directory=AsyncPath("templates"),
474 context_processors=gather_result.context_processors,
475 extensions=gather_result.extensions,
476 bytecode_cache=bytecode_cache,
477 enable_async=True,
478 )
480 if choice_loader:
481 templates.env.loader = choice_loader
483 for name, value in gather_result.globals.items():
484 templates.env.globals[name] = value
486 for name, filter_func in gather_result.filters.items():
487 templates.env.filters[name] = filter_func
489 templates.env.block_start_string = "[%"
490 templates.env.block_end_string = "%]"
491 templates.env.variable_start_string = "[["
492 templates.env.variable_end_string = "]]"
493 templates.env.comment_start_string = "[#"
494 templates.env.comment_end_string = "#]"
496 debug("Created Jinja2 environment with gathered components")
497 return templates
500def register_template_filters(
501 templates: t.Any,
502 filters: dict[str, t.Callable[..., t.Any]],
503) -> None:
504 for name, filter_func in filters.items():
505 if hasattr(templates, "filter"):
506 templates.filter(name)(filter_func)
507 else:
508 templates.env.filters[name] = filter_func
510 debug(f"Registered {len(filters)} template filters")