"""
Jinja2 environment augmentation utilities.
This module provides helpers for enriching a :class:`jinja2.Environment` with
Python built-ins, common text-processing filters, and selected operating system
environment variables. It is intended to simplify template authoring by making
common Python functions available as filters, tests, and globals, while keeping
target-language-specific helpers opt-in through each template's
``config.yaml``.
The module contains the following public functions:
* :func:`add_builtins_to_env` - Register Python built-ins as filters, tests, and globals
* :func:`add_settings_for_env` - Apply built-ins plus additional filters and globals
* :func:`to_c_path_identifier` - Build collision-resistant C path identifiers
* :func:`to_c_public_identifier` - Build public C/C++ identifiers that avoid reserved forms
* :func:`to_c_public_macro_identifier` - Build public C/C++ macro identifiers
* :func:`is_c_public_identifier_reserved` - Detect reserved public C/C++ identifier shapes
.. note::
The added filters and globals are only attached to the environment instance
passed to the functions and do not affect other environments. C-family
identifier helpers are exported from this module for explicit template
imports, but they are not injected into every renderer environment by
default.
Example::
>>> import jinja2
>>> from pyfcstm.utils.jinja2 import add_settings_for_env
>>> env = add_settings_for_env(jinja2.Environment())
>>> template = env.from_string("{{ 'Hello World'|to_identifier }}")
>>> template.render()
'Hello_World'
"""
import builtins
import inspect
import os
import textwrap
import jinja2
from .text import normalize, to_identifier, to_c_identifier
[docs]
def to_c_path_identifier(segments) -> str:
"""
Convert a path sequence into a collision-resistant C identifier.
Each path segment is encoded independently and prefixed with its original
length. Encoding segment boundaries this way avoids collisions such as
``Root.A.B`` and ``Root.A_B``. Preserving case while escaping significant
underscores also avoids collisions such as ``Root.A`` and ``Root.a`` or
``Internal`` and ``Internal_`` while keeping the output acceptable for
C/C++ identifiers and out of the C++ reserved double-underscore namespace.
:param segments: Iterable path segments to encode.
:type segments: typing.Iterable[str]
:return: C/C++-safe path identifier.
:rtype: str
Example::
>>> to_c_path_identifier(["Root", "A", "B"])
'p4_Root_p1_A_p1_B'
>>> to_c_path_identifier(["Root", "A_B"])
'p4_Root_p3_Az00005FB'
>>> to_c_path_identifier(["Root", "A__B"])
'p4_Root_p4_Az00005Fz00005FB'
>>> to_c_path_identifier(["Root", "Internal_"])
'p4_Root_p9_Internalz00005F'
"""
parts = []
for segment in segments:
raw_segment = str(segment)
encoded_chars = []
for char in raw_segment:
if "0" <= char <= "9" or "A" <= char <= "Z" or "a" <= char <= "y":
encoded_chars.append(char)
else:
encoded_chars.append("z%06X" % ord(char))
item = "".join(encoded_chars) or "empty"
parts.append("p%d_%s" % (len(raw_segment), item))
return "_".join(parts) or "p0_empty"
def _has_reserved_c_public_shape(identifier: str) -> bool:
"""
Check whether a generated public C/C++ identifier has a reserved shape.
:param identifier: Identifier spelling to inspect.
:type identifier: str
:return: Whether the spelling should be avoided for public generated names.
:rtype: bool
"""
return identifier.startswith("_") or "__" in identifier
[docs]
def is_c_public_identifier_reserved(identifier: str) -> bool:
"""
Return whether a public C/C++ identifier spelling should be avoided.
Generated public macros, typedefs, and function prefixes should avoid names
that C or C++ reserves for implementations. This helper checks the shapes
that matter for generated public identifiers: a leading underscore or any
double underscore.
:param identifier: Identifier spelling to inspect.
:type identifier: str
:return: Whether the spelling has a reserved public C/C++ shape.
:rtype: bool
Example::
>>> is_c_public_identifier_reserved('_ROOT_MACHINE')
True
>>> is_c_public_identifier_reserved('ROOT__MACHINE')
True
>>> is_c_public_identifier_reserved('ROOT_MACHINE')
False
"""
return _has_reserved_c_public_shape(identifier)
[docs]
def to_c_public_identifier(input_string: str, suffix: str = "") -> str:
"""
Convert text into a public C/C++ identifier that avoids reserved forms.
The helper keeps ordinary identifiers readable, while falling back to the
lossless path encoder whenever appending ``suffix`` would create a public
spelling with a leading underscore or a double underscore. This is intended
for generated C-family public ABI prefixes such as root-machine typedefs,
function prefixes, visibility macros, and header-guard components.
:param input_string: Source text to encode.
:type input_string: str
:param suffix: Optional suffix to append to the generated identifier,
defaults to ``''``.
:type suffix: str, optional
:return: Public C/C++ identifier spelling.
:rtype: str
Example::
>>> to_c_public_identifier('Root', 'Machine')
'RootMachine'
>>> to_c_public_identifier('_Root', 'Machine')
'p_p5_z00005FRootMachine'
>>> to_c_public_identifier('class', '_MACHINE')
'p_p5_class_MACHINE'
"""
candidate = to_c_identifier(input_string) + suffix
if not _has_reserved_c_public_shape(candidate):
return candidate
return "p_%s%s" % (to_c_path_identifier([input_string]), suffix)
[docs]
def to_c_public_macro_identifier(input_string: str, suffix: str = "") -> str:
"""
Convert text into an uppercase public C/C++ macro identifier.
This helper mirrors :func:`to_c_public_identifier` and uppercases the final
spelling for generated public macro prefixes and header guards.
:param input_string: Source text to encode.
:type input_string: str
:param suffix: Optional suffix to append before uppercasing, defaults to
``''``.
:type suffix: str, optional
:return: Public C/C++ macro identifier spelling.
:rtype: str
Example::
>>> to_c_public_macro_identifier('Root', '_MACHINE')
'ROOT_MACHINE'
>>> to_c_public_macro_identifier('_Root', '_MACHINE')
'P_P5_Z00005FROOT_MACHINE'
"""
return to_c_public_identifier(input_string, suffix).upper()
[docs]
def add_builtins_to_env(env: jinja2.Environment) -> jinja2.Environment:
"""
Mount Python built-in functions to a Jinja2 environment.
This function registers Python built-ins to the provided environment as:
- **Filters**: Callable built-ins are added as filters when no naming
conflict exists.
- **Tests**: Common boolean checks are added as tests, using a simplified
name for functions beginning with ``is`` (e.g., ``isinstance`` becomes
the ``instance`` test).
- **Globals**: All non-conflicting built-ins are added to the global
namespace.
In addition to these automatic registrations, this function always injects
several convenience filters, even if they overwrite existing names in the
environment:
- ``str``: :class:`str`
- ``set``: :class:`set`
- ``dict``: :class:`dict`
- ``keys``: ``lambda x: x.keys()``
- ``values``: ``lambda x: x.values()``
- ``enumerate``: :func:`enumerate`
- ``reversed``: :func:`reversed`
- ``filter``: ``lambda x, y: filter(y, x)``
:param env: A Jinja2 environment instance to modify.
:type env: jinja2.Environment
:return: The same Jinja2 environment with built-ins mounted.
:rtype: jinja2.Environment
.. warning::
This function may override pre-existing filters named ``str``, ``set``,
``dict``, ``keys``, ``values``, ``enumerate``, ``reversed``, and
``filter``.
Example::
>>> import jinja2
>>> env = add_builtins_to_env(jinja2.Environment())
>>> tmpl = env.from_string("{{ [1, 2, 3]|reversed|list }}")
>>> tmpl.render()
'[3, 2, 1]'
"""
existing_filters = set(env.filters.keys())
existing_tests = set(env.tests.keys())
existing_globals = set(env.globals.keys())
builtin_items = [
(name, obj)
for name, obj in inspect.getmembers(builtins)
if not name.startswith("_")
]
for name, func in builtin_items:
if not callable(func):
continue
is_filter_candidate = inspect.isfunction(func) or inspect.isbuiltin(func)
is_test_candidate = name.startswith("is") or name in (
"all",
"any",
"callable",
"hasattr",
)
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)
test_name = name
if name.startswith("is"):
test_name = name[2:].lower()
if is_test_candidate and test_name not in existing_tests:
env.tests[test_name] = func
if name not in existing_globals:
env.globals[name] = func
return env
[docs]
def add_settings_for_env(env: jinja2.Environment) -> jinja2.Environment:
"""
Add built-ins, text filters, and environment variables to a Jinja2 environment.
This function enhances a Jinja2 environment by applying the following steps:
1. Register Python built-ins via :func:`add_builtins_to_env`
2. Add text-processing filters:
- ``normalize``: :func:`pyfcstm.utils.text.normalize`
- ``to_identifier``: :func:`pyfcstm.utils.text.to_identifier`
3. Add a global helper:
- ``indent``: :func:`textwrap.indent`
4. Add operating system environment variables as globals (only if the name
does not already exist in the environment).
:param env: The Jinja2 environment to enhance.
:type env: jinja2.Environment
:return: The enhanced Jinja2 environment.
:rtype: jinja2.Environment
.. note::
Environment variables are only added if their names do not already exist
in the environment's global namespace.
Example::
>>> import jinja2
>>> env = add_settings_for_env(jinja2.Environment())
>>> template = env.from_string("{{ 'Hello World'|normalize }}")
>>> template.render()
'Hello_World'
"""
env = add_builtins_to_env(env)
env.filters["normalize"] = normalize
env.filters["to_identifier"] = to_identifier
env.globals["indent"] = textwrap.indent
for key, value in os.environ.items():
if key not in env.globals:
env.globals[key] = value
return env