Files
esphome-lsm6ds/esphome/components/substitutions/jinja.py
T

194 lines
6.7 KiB
Python

from ast import literal_eval
from collections.abc import Iterator, Mapping
from itertools import chain, islice
import math
from types import GeneratorType
from typing import Any
import jinja2 as jinja
from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate
from jinja2.runtime import missing as Missing
# Re-exported for backward compatibility — consumers import has_jinja from here
from esphome.expression import has_jinja # noqa: F401 # pylint: disable=unused-import
TemplateError = jinja.TemplateError
TemplateSyntaxError = jinja.TemplateSyntaxError
TemplateRuntimeError = jinja.TemplateRuntimeError
UndefinedError = jinja.UndefinedError
Undefined = jinja.Undefined
# Sentinel key for resolver callback in ContextVars.
# Dots are invalid in substitution names so this can never collide with user keys.
Resolver = ".resolver"
# SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose
# in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow
# arbitrary code execution, file access, or other security risks are included.
#
# The following functions are considered safe:
# - math: The entire math module is injected, allowing access to mathematical functions like sin, cos, sqrt, etc.
# - ord: Converts a character to its Unicode code point integer.
# - chr: Converts an integer to its corresponding Unicode character.
# - len: Returns the length of a sequence or collection.
#
# These functions were chosen because they are pure, have no side effects, and do not provide access
# to the file system, environment, or other potentially sensitive resources.
SAFE_GLOBALS = {
"math": math, # Inject entire math module
"ord": ord,
"chr": chr,
"len": len,
}
class JinjaError(Exception):
def __init__(self, context_trace: dict, expr: str):
self.context_trace = context_trace
self.eval_stack = [expr]
def parent(self):
return self.__context__
def error_name(self):
return type(self.parent()).__name__
def context_trace_str(self):
return "\n".join(
f" {k} = {repr(v)} ({type(v).__name__})"
for k, v in self.context_trace.items()
)
def stack_trace_str(self):
return "\n".join(
f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}"
for i, expr in enumerate(self.eval_stack)
)
class TrackerContext(jinja.runtime.Context):
def resolve_or_missing(self, key):
val = super().resolve_or_missing(key)
if val is Missing:
# Variable not in the template context — check if a resolver callback
# was registered (by _push_context) to lazily resolve dependencies
# between substitution variables in the same block.
resolver = super().resolve_or_missing(Resolver)
if resolver is not Missing:
val = resolver(key)
self.environment.context_trace[key] = val
return val
def _concat_nodes_override(values: Iterator[Any]) -> Any:
"""
This function customizes how Jinja preserves native types when concatenating
multiple result nodes together. If the result is a single node, its value
is returned. Otherwise, the nodes are concatenated as strings. If
the result can be parsed with `ast.literal_eval`, the parsed
value is returned. Otherwise, the string is returned.
This helps preserve metadata such as ESPHomeDataBase from original values
and mimicks how HomeAssistant deals with template evaluation and preserving
the original datatype.
"""
head: list[Any] = list(islice(values, 2))
if not head:
return None
if len(head) == 1:
raw = head[0]
if not isinstance(raw, str):
return raw
else:
if isinstance(values, GeneratorType):
values = chain(head, values)
raw = "".join([str(v) for v in values])
result = None
try:
# Attempt to parse the concatenated string into a Python literal.
# This allows expressions like "1 + 2" to be evaluated to the integer 3.
# If the result is also a string or there is a parsing error,
# fall back to returning the raw string. This is consistent with
# Home Assistant's behavior when evaluating templates
result = literal_eval(raw)
except (ValueError, SyntaxError, MemoryError, TypeError):
pass
else:
if isinstance(result, set):
# Sets are not supported, return raw string
return raw
if not isinstance(result, str):
return result
return raw
class Jinja(jinja.Environment):
"""Jinja environment configured for ESPHome substitution expressions."""
# jinja environment customization overrides
code_generator_class = NativeCodeGenerator
concat = staticmethod(_concat_nodes_override)
def __init__(self) -> None:
super().__init__(
trim_blocks=True,
lstrip_blocks=True,
block_start_string="<%",
block_end_string="%>",
line_statement_prefix="#",
line_comment_prefix="##",
variable_start_string="${",
variable_end_string="}",
undefined=jinja.StrictUndefined,
)
self.context_class = TrackerContext
self.add_extension("jinja2.ext.do")
self.context_trace = {}
self.globals = {**self.globals, **SAFE_GLOBALS}
def expand(self, content_str: str, context_vars: Mapping[str, Any]) -> Any:
"""
Renders a string that may contain Jinja expressions or statements
Returns the resulting value if all variables and expressions could be resolved.
"""
result = None
old_trace = self.context_trace
self.context_trace = {}
try:
template = self.from_string(content_str)
result = template.render(context_vars)
if isinstance(result, Undefined):
str(result) # force a UndefinedError exception
except UndefinedError as err:
raise err
except JinjaError as err:
err.context_trace = {**self.context_trace, **err.context_trace}
err.eval_stack.append(content_str)
raise err
except (
TemplateError,
TemplateRuntimeError,
RuntimeError,
ArithmeticError,
AttributeError,
TypeError,
) as err:
raise JinjaError(self.context_trace, content_str) from err
finally:
self.context_trace = old_trace
return result
class JinjaTemplate(NativeTemplate):
environment_class = Jinja
Jinja.template_class = JinjaTemplate