from ast import literal_eval import logging import math import re import jinja2 as jinja from jinja2.sandbox import SandboxedEnvironment from esphome.yaml_util import ESPLiteralValue TemplateError = jinja.TemplateError TemplateSyntaxError = jinja.TemplateSyntaxError TemplateRuntimeError = jinja.TemplateRuntimeError UndefinedError = jinja.UndefinedError Undefined = jinja.Undefined _LOGGER = logging.getLogger(__name__) DETECT_JINJA = r"(\$\{)" detect_jinja_re = re.compile( r"<%.+?%>" # Block form expression: <% ... %> r"|\$\{[^}]+\}", # Braced form expression: ${ ... } flags=re.MULTILINE, ) def has_jinja(st): return detect_jinja_re.search(st) is not None # 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 JinjaStr(str): """ Wraps a string containing an unresolved Jinja expression, storing the variables visible to it when it failed to resolve. For example, an expression inside a package, `${ A * B }` may fail to resolve at package parsing time if `A` is a local package var but `B` is a substitution defined in the root yaml. Therefore, we store the value of `A` as an upvalue bound to the original string so we may be able to resolve `${ A * B }` later in the main substitutions pass. """ Undefined = object() def __new__(cls, value: str, upvalues=None): if isinstance(value, JinjaStr): base = str(value) merged = {**value.upvalues, **(upvalues or {})} else: base = value merged = dict(upvalues or {}) obj = super().__new__(cls, base) obj.upvalues = merged obj.result = JinjaStr.Undefined return obj 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 isinstance(val, JinjaStr): self.environment.context_trace[key] = val val, _ = self.environment.expand(val) self.environment.context_trace[key] = val return val class Jinja(SandboxedEnvironment): """ Wraps a Jinja environment """ def __init__(self, context_vars): 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.context_vars = {**context_vars} for k, v in self.context_vars.items(): if isinstance(v, ESPLiteralValue): continue if isinstance(v, str) and not isinstance(v, JinjaStr) and has_jinja(v): self.context_vars[k] = JinjaStr(v, self.context_vars) self.globals = { **self.globals, **self.context_vars, **SAFE_GLOBALS, } def safe_eval(self, expr): try: result = literal_eval(expr) if not isinstance(result, str): return result except (ValueError, SyntaxError, MemoryError, TypeError): pass return expr def expand(self, content_str): """ Renders a string that may contain Jinja expressions or statements Returns the resulting processed string if all values could be resolved. Otherwise, it returns a tagged (JinjaStr) string that captures variables in scope (upvalues), like a closure for later evaluation. """ result = None override_vars = {} if isinstance(content_str, JinjaStr): if content_str.result is not JinjaStr.Undefined: return content_str.result, None # If `value` is already a JinjaStr, it means we are trying to evaluate it again # in a parent pass. # Hopefully, all required variables are visible now. override_vars = content_str.upvalues old_trace = self.context_trace self.context_trace = {} try: template = self.from_string(content_str) result = self.safe_eval(template.render(override_vars)) if isinstance(result, Undefined): print("" + result) # force a UndefinedError exception except (TemplateSyntaxError, UndefinedError) as err: # `content_str` contains a Jinja expression that refers to a variable that is undefined # in this scope. Perhaps it refers to a root substitution that is not visible yet. # Therefore, return `content_str` as a JinjaStr, which contains the variables # that are actually visible to it at this point to postpone evaluation. return JinjaStr(content_str, {**self.context_vars, **override_vars}), 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 if isinstance(content_str, JinjaStr): content_str.result = result return result, None