Source code for metadata_crawler.api.mixin.template_mixin

"""Definitions for jinja2 templating."""

import os
from functools import lru_cache
from typing import Any, Dict, Mapping, Optional

from jinja2 import Environment, Template, Undefined

ENV = Environment(undefined=Undefined, autoescape=True)


@lru_cache(maxsize=1024)
def _compile_jinja_template(s: str) -> Template:
    return ENV.from_string(s)


[docs] class TemplateMixin: """Apply templating egine jinja2.""" env_map: Optional[Dict[str, str]] = None _rendered = False
[docs] def prep_template_env(self) -> None: """Prepare the jinja2 env.""" def _env_get(name: str, default: Optional[str] = None) -> Optional[str]: return os.getenv(name, default) def _getenv_filter( varname: str, default: Optional[str] = None ) -> Optional[str]: return os.getenv(varname, default) ENV.globals.setdefault("env", _env_get) ENV.globals.setdefault("ENV", dict(os.environ)) ENV.filters.setdefault("getenv", _getenv_filter) self._rendered = True
[docs] def render_templates( self, data: Any, context: Mapping[str, Any], *, max_passes: int = 2, ) -> Any: """Recursively render Jinja2 templates found in strings within data. This function traverses common container types (``dict``, ``list``, ``tuple``, ``set``), dataclasses, namedtuples, and ``pathlib.Path`` objects. Every string encountered is treated as a Jinja2 template and rendered with the provided ``context``. Rendering can be repeated up to ``max_passes`` times to resolve templates that produce further templates on the first pass. Parameters ^^^^^^^^^^ data: Arbitrary Python data structure. Supported containers are ``dict`` (keys and values), ``list``, ``tuple`` (including namedtuples), ``set``, dataclasses (fields), and ``pathlib.Path``. Scalars (e.g., ``int``, ``float``, ``bool``, ``None``) are returned unchanged. Strings are rendered as Jinja2 templates. context: Mapping of template variables available to Jinja2 during rendering. max_passes: Maximum number of rendering passes to perform on each string, by default ``2``. Increase this if templates generate further templates that need resolution. Returns ^^^^^^^ Any: A structure of the same shape with all strings rendered. Container and object types are preserved where feasible (e.g., ``tuple`` stays a ``tuple``, namedtuple stays a namedtuple, dataclass remains the same dataclass type). Raises ^^^^^^^ jinja2.TemplateError For other Jinja2 template errors encountered during rendering. Notes ^^^^^^ * Dictionary keys are also rendered if they are strings (or nested containers with strings). If rendering causes key collisions, the **last** rendered key wins. * For dataclasses, all fields are rendered and a new instance is returned using ``dataclasses.replace``. Frozen dataclasses are supported. * Namedtuples are detected via the ``_fields`` attribute and reconstructed with the same type. Examples ^^^^^^^^^ .. code-block:: python data = { "greeting": "Hello, {{ name }}!", "items": ["{{ count }} item(s)", 42], "path": {"root": "/home/{{ user }}", "cfg": "{{ root }}/cfg"}, } ctx = {"name": "Ada", "count": 3, "user": "ada", "root": "/opt/app"} TemplateMixin().render_templates(data, ctx) # {'greeting': 'Hello, Ada!', # 'items': ['3 item(s)', 42], # 'path': {'root': '/home/ada', 'cfg': '/opt/app/cfg'}} """ if not self._rendered: self.prep_template_env() def _render_str(s: str) -> str: out = s if ("{{" not in s) and ("{%" not in s): return out for _ in range(max_passes): new = _compile_jinja_template(out).render(context) if new == out: break out = new return out def _walk(obj: Any) -> Any: if isinstance(obj, str): return _render_str(obj) if isinstance(obj, dict): rendered: dict[Any, Any] = {} for k, v in obj.items(): rk = _render_str(k) if isinstance(k, str) else k rendered[rk] = _walk(v) return rendered if isinstance(obj, list): return [_walk(x) for x in obj] if isinstance(obj, tuple): return tuple(_walk(x) for x in obj) if isinstance(obj, set): return {_walk(x) for x in obj} return obj return _walk(data)