Source code for hbllmutils.template.env

"""
Jinja2 environment enhancement utilities.

This module provides helper functions for configuring :class:`jinja2.Environment`
instances with Python built-ins, environment variables, and custom text helpers.
The goal is to make template authoring more expressive by exposing commonly
needed functionality as filters, tests, and globals.

The module contains the following main components:

* :func:`add_builtins_to_env` - Mount Python built-ins as filters, tests, and globals
* :func:`add_settings_for_env` - Add built-ins, text helpers, and environment variables
* :func:`create_env` - Create a fully configured Jinja2 environment

Example::

    >>> import jinja2
    >>> from hbllmutils.template.env import create_env
    >>> env = create_env()
    >>> template = env.from_string("{{ 3 | ordinalize }} and {{ 'word' | plural }}")
    >>> template.render()
    '3rd and words'

.. note::
   The environment exposes all current OS environment variables as globals.
   Use this carefully when rendering templates with untrusted input.

"""

import builtins
import inspect
import os
import pathlib
import textwrap
from typing import Union

import jinja2
from hbutils.string import plural_word, ordinalize, titleize
from jinja2 import StrictUndefined, Undefined


[docs] def add_builtins_to_env(env: jinja2.Environment) -> jinja2.Environment: """ Mount Python built-in functions to a Jinja2 environment. This function inspects Python's built-in namespace and mounts functions into a Jinja2 environment as filters, tests, and globals. Filters and tests are added when no conflicting entries already exist. The mounting strategy follows these heuristics: * Filters: any callable built-in is added as a filter if no name conflict exists. * Tests: callables with an ``is`` prefix, plus common boolean functions (``all``, ``any``, ``callable``, ``hasattr``) are added as tests. * Globals: all built-in callables are exposed as globals if no conflict exists. In addition, a few convenience filter aliases are always ensured: * ``str``, ``set``, ``dict``, ``keys``, ``values``, ``enumerate``, ``reversed``, ``filter`` :param env: A Jinja2 environment instance to be enhanced. :type env: jinja2.Environment :return: The enhanced environment (same instance as input). :rtype: jinja2.Environment Example:: >>> import jinja2 >>> env = add_builtins_to_env(jinja2.Environment()) >>> env.from_string("{{ items | len }}").render(items=[1, 2, 3]) '3' >>> env.from_string("{{ value is none }}").render(value=None) 'True' .. note:: Built-in names that conflict with existing filters or tests are not overridden, preserving any user-defined behavior. """ # Existing built-in filters, tests and global functions in Jinja2 existing_filters = set(env.filters.keys()) existing_tests = set(env.tests.keys()) existing_globals = set(env.globals.keys()) # Get all Python built-in functions builtin_items = [ (name, obj) for name, obj in inspect.getmembers(builtins) if not name.startswith("_") ] # Categorize functions for appropriate mounting positions for name, func in builtin_items: # Skip non-callable objects if not callable(func): continue # Determine if the function is suitable as a filter is_filter_candidate = inspect.isfunction(func) or inspect.isbuiltin(func) # Determine if the function is suitable as a tester is_test_candidate = ( name.startswith("is") or name in ("all", "any", "callable", "hasattr") ) # Mount as a filter (if suitable and no conflict) filter_name = name if is_filter_candidate and filter_name not in existing_filters: env.filters[filter_name] = func env.filters["str"] = str env.filters["set"] = set env.filters["dict"] = dict env.filters["keys"] = lambda x: x.keys() env.filters["values"] = lambda x: x.values() env.filters["enumerate"] = enumerate env.filters["reversed"] = reversed env.filters["filter"] = lambda x, y: filter(y, x) # Mount as a tester (if suitable and no conflict) test_name = name if name.startswith("is"): # For functions starting with 'is', the prefix can be removed as the tester name test_name = name[2:].lower() if is_test_candidate and test_name not in existing_tests: env.tests[test_name] = func # Mount as a global function (if no conflict) if name not in existing_globals: env.globals[name] = func return env
def _read_file_text(path: Union[str, os.PathLike]) -> str: """ Read the entire contents of a file path as text. This helper is used to provide a Jinja2 filter and global callable that can read text content in templates. :param path: File system path to read. :type path: str or os.PathLike :return: File contents as a string. :rtype: str """ return pathlib.Path(path).read_text()
[docs] def add_settings_for_env(env: jinja2.Environment) -> jinja2.Environment: """ Add additional settings and helper functions to a Jinja2 environment. This function enhances a Jinja2 environment by: #. Adding Python built-in functions via :func:`add_builtins_to_env`. #. Adding text processing filters and globals: * ``indent`` - Indent text using :func:`textwrap.indent` * ``plural`` - Pluralize a word with its count using :func:`hbutils.string.plural_word` * ``ordinalize`` - Convert numbers to ordinal form using :func:`hbutils.string.ordinalize` * ``titleize`` - Convert text to title case using :func:`hbutils.string.titleize` * ``read_file_text`` - Read text content from a file path #. Adding all current environment variables as global variables, allowing template authors to reference them directly. :param env: The Jinja2 environment to enhance. :type env: jinja2.Environment :return: The enhanced environment (same instance as input). :rtype: jinja2.Environment Example:: >>> import jinja2 >>> env = add_settings_for_env(jinja2.Environment()) >>> env.from_string("{{ 'word' | plural }}").render() 'words' >>> env.from_string("{{ 3 | ordinalize }}").render() '3rd' .. warning:: Environment variables are added as globals. If templates are rendered from untrusted sources, avoid exposing sensitive environment variables. """ env = add_builtins_to_env(env) env.globals["indent"] = env.filters["indent"] = textwrap.indent env.globals["plural_word"] = env.filters["plural"] = plural_word env.globals["ordinalize"] = env.filters["ordinalize"] = ordinalize env.globals["titleize"] = env.filters["titleize"] = titleize env.globals["read_file_text"] = env.filters["read_file_text"] = _read_file_text for key, value in os.environ.items(): if key not in env.globals: env.globals[key] = value return env
[docs] def create_env(strict_undefined: bool = True) -> jinja2.Environment: """ Create a new Jinja2 environment with enhanced settings. This is a convenience function that builds a :class:`jinja2.Environment` instance and applies :func:`add_settings_for_env`. It optionally configures the undefined-variable behavior using :class:`jinja2.StrictUndefined`. :param strict_undefined: If ``True`` (default), use :class:`jinja2.StrictUndefined` to raise errors for undefined variables. If ``False``, use the default :class:`jinja2.Undefined` behavior. :type strict_undefined: bool :return: A fully configured Jinja2 environment with all enhancements. :rtype: jinja2.Environment Example:: >>> env = create_env() >>> env.from_string("{{ 'hello' | upper }}").render() 'HELLO' >>> env.from_string("{{ 3 | ordinalize }}").render() '3rd' .. note:: Use ``strict_undefined=False`` if you prefer Jinja2's default behavior, where undefined variables render as empty strings. """ env = jinja2.Environment( undefined=StrictUndefined if strict_undefined else Undefined ) env = add_settings_for_env(env) return env