134 lines
4.2 KiB
Python
134 lines
4.2 KiB
Python
"""Shared Jinja2 environment helpers for unit tests and template rendering.
|
|
|
|
Creating a Jinja2 Environment is expensive (filesystem scanning, template
|
|
compilation). These helpers create cached environments with
|
|
``auto_reload=False`` to skip stat() calls on every ``get_template``,
|
|
which is the single biggest speedup for template-heavy test suites.
|
|
|
|
The filters mimic Ansible builtins not available in plain Jinja2,
|
|
making it possible to render Ansible templates outside of Ansible
|
|
(e.g. in unit tests or config generation scripts).
|
|
|
|
Usage::
|
|
|
|
from devx.utils.jinja import make_env, render_template
|
|
|
|
env = make_env("/path/to/templates")
|
|
output = render_template(env, "alert-rules.yml.j2", grafana_base_url="https://grafana.example.com")
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import json
|
|
import re
|
|
|
|
import jinja2
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filters (mimic Ansible builtins not available in plain Jinja2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def to_json(value) -> str:
|
|
return json.dumps(value)
|
|
|
|
|
|
def to_bool(value) -> bool:
|
|
"""Mimic Ansible's |bool filter for plain Jinja2 tests."""
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
return value.lower() not in ("", "false", "0", "no", "off", "null", "none")
|
|
return bool(value)
|
|
|
|
|
|
def regex_replace(value, pattern: str, replacement: str) -> str:
|
|
"""Mimic Ansible's |regex_replace filter."""
|
|
return re.sub(pattern, replacement, str(value))
|
|
|
|
|
|
def regex_escape(value) -> str:
|
|
"""Mimic Ansible's |regex_escape filter."""
|
|
return re.escape(str(value))
|
|
|
|
|
|
def regex_search(value, pattern: str) -> str | None:
|
|
"""Mimic Ansible's |regex_search filter.
|
|
|
|
Returns the first match (group 0) or None if no match.
|
|
Ansible returns the full match string or None.
|
|
"""
|
|
m = re.search(pattern, str(value))
|
|
return m.group(0) if m else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Environment factory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FILTERS = {
|
|
"to_json": to_json,
|
|
"bool": to_bool,
|
|
"regex_replace": regex_replace,
|
|
"regex_escape": regex_escape,
|
|
"regex_search": regex_search,
|
|
}
|
|
|
|
|
|
@functools.cache
|
|
def make_env(loader_path: str) -> jinja2.Environment:
|
|
"""Create a cached Jinja2 Environment with standard filters.
|
|
|
|
``auto_reload=False`` skips stat() on every get_template call —
|
|
templates don't change during a test run so this is safe and
|
|
cuts ~40% off render time.
|
|
"""
|
|
env = jinja2.Environment( # nosec B701 — renders YAML/config templates, not HTML
|
|
loader=jinja2.FileSystemLoader(loader_path),
|
|
undefined=jinja2.StrictUndefined,
|
|
auto_reload=False,
|
|
cache_size=400,
|
|
)
|
|
env.filters.update(_FILTERS)
|
|
return env
|
|
|
|
|
|
@functools.cache
|
|
def make_value_env() -> jinja2.Environment:
|
|
"""Cached environment for rendering individual manifest string values."""
|
|
env = jinja2.Environment( # nosec B701 — renders config values, not HTML
|
|
undefined=jinja2.ChainableUndefined,
|
|
auto_reload=False,
|
|
)
|
|
env.filters.update(_FILTERS)
|
|
return env
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Render helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def render_template(env: jinja2.Environment, template_name: str, **kwargs) -> str:
|
|
"""Render a named template from a FileSystemLoader-backed env."""
|
|
return env.get_template(template_name).render(**kwargs)
|
|
|
|
|
|
def render_value(value, ctx: dict):
|
|
"""Render a single string value as a Jinja2 template if it contains expressions."""
|
|
if not isinstance(value, str):
|
|
return value
|
|
if "{{" not in value and "{%" not in value:
|
|
return value
|
|
return make_value_env().from_string(value).render(**ctx)
|
|
|
|
|
|
def render_manifest_values(obj, ctx: dict):
|
|
"""Recursively render all Jinja2 expressions in manifest string values."""
|
|
if isinstance(obj, dict):
|
|
return {k: render_manifest_values(v, ctx) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [render_manifest_values(v, ctx) for v in obj]
|
|
return render_value(obj, ctx)
|