mirror of
https://github.com/esphome/esphome.git
synced 2025-11-01 15:41:52 +00:00
Co-authored-by: J. Nick Koston <nick+github@koston.org> Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from re import Match
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from esphome import core
|
from esphome import core
|
||||||
from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered
|
from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered
|
||||||
@@ -39,7 +41,34 @@ async def to_code(config):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _expand_jinja(value, orig_value, path, jinja, ignore_missing):
|
def _restore_data_base(value: Any, orig_value: ESPHomeDataBase) -> ESPHomeDataBase:
|
||||||
|
"""This function restores ESPHomeDataBase metadata held by the original string.
|
||||||
|
This is needed because during jinja evaluation, strings can be replaced by other types,
|
||||||
|
but we want to keep the original metadata for error reporting and source mapping.
|
||||||
|
For example, if a substitution replaces a string with a dictionary, we want that items
|
||||||
|
in the dictionary to still point to the original document location
|
||||||
|
"""
|
||||||
|
if isinstance(value, ESPHomeDataBase):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
_restore_data_base(k, orig_value): _restore_data_base(v, orig_value)
|
||||||
|
for k, v in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_restore_data_base(v, orig_value) for v in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return make_data_base(value, orig_value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_jinja(
|
||||||
|
value: str | JinjaStr,
|
||||||
|
orig_value: str | JinjaStr,
|
||||||
|
path,
|
||||||
|
jinja: Jinja,
|
||||||
|
ignore_missing: bool,
|
||||||
|
) -> Any:
|
||||||
if has_jinja(value):
|
if has_jinja(value):
|
||||||
# If the original value passed in to this function is a JinjaStr, it means it contains an unresolved
|
# If the original value passed in to this function is a JinjaStr, it means it contains an unresolved
|
||||||
# Jinja expression from a previous pass.
|
# Jinja expression from a previous pass.
|
||||||
@@ -65,10 +94,17 @@ def _expand_jinja(value, orig_value, path, jinja, ignore_missing):
|
|||||||
f"\nSee {'->'.join(str(x) for x in path)}",
|
f"\nSee {'->'.join(str(x) for x in path)}",
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
|
# If the original, unexpanded string, contained document metadata (ESPHomeDatabase),
|
||||||
|
# assign this same document metadata to the resulting value.
|
||||||
|
if isinstance(orig_value, ESPHomeDataBase):
|
||||||
|
value = _restore_data_base(value, orig_value)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _expand_substitutions(substitutions, value, path, jinja, ignore_missing):
|
def _expand_substitutions(
|
||||||
|
substitutions: dict, value: str, path, jinja: Jinja, ignore_missing: bool
|
||||||
|
) -> Any:
|
||||||
if "$" not in value:
|
if "$" not in value:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -76,14 +112,14 @@ def _expand_substitutions(substitutions, value, path, jinja, ignore_missing):
|
|||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while True:
|
while True:
|
||||||
m = cv.VARIABLE_PROG.search(value, i)
|
m: Match[str] = cv.VARIABLE_PROG.search(value, i)
|
||||||
if not m:
|
if not m:
|
||||||
# No more variable substitutions found. See if the remainder looks like a jinja template
|
# No more variable substitutions found. See if the remainder looks like a jinja template
|
||||||
value = _expand_jinja(value, orig_value, path, jinja, ignore_missing)
|
value = _expand_jinja(value, orig_value, path, jinja, ignore_missing)
|
||||||
break
|
break
|
||||||
|
|
||||||
i, j = m.span(0)
|
i, j = m.span(0)
|
||||||
name = m.group(1)
|
name: str = m.group(1)
|
||||||
if name.startswith("{") and name.endswith("}"):
|
if name.startswith("{") and name.endswith("}"):
|
||||||
name = name[1:-1]
|
name = name[1:-1]
|
||||||
if name not in substitutions:
|
if name not in substitutions:
|
||||||
@@ -98,7 +134,7 @@ def _expand_substitutions(substitutions, value, path, jinja, ignore_missing):
|
|||||||
i = j
|
i = j
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sub = substitutions[name]
|
sub: Any = substitutions[name]
|
||||||
|
|
||||||
if i == 0 and j == len(value):
|
if i == 0 and j == len(value):
|
||||||
# The variable spans the whole expression, e.g., "${varName}". Return its resolved value directly
|
# The variable spans the whole expression, e.g., "${varName}". Return its resolved value directly
|
||||||
@@ -121,7 +157,13 @@ def _expand_substitutions(substitutions, value, path, jinja, ignore_missing):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _substitute_item(substitutions, item, path, jinja, ignore_missing):
|
def _substitute_item(
|
||||||
|
substitutions: dict,
|
||||||
|
item: Any,
|
||||||
|
path: list[int | str],
|
||||||
|
jinja: Jinja,
|
||||||
|
ignore_missing: bool,
|
||||||
|
) -> Any | None:
|
||||||
if isinstance(item, ESPLiteralValue):
|
if isinstance(item, ESPLiteralValue):
|
||||||
return None # do not substitute inside literal blocks
|
return None # do not substitute inside literal blocks
|
||||||
if isinstance(item, list):
|
if isinstance(item, list):
|
||||||
@@ -160,7 +202,9 @@ def _substitute_item(substitutions, item, path, jinja, ignore_missing):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def do_substitution_pass(config, command_line_substitutions, ignore_missing=False):
|
def do_substitution_pass(
|
||||||
|
config: dict, command_line_substitutions: dict, ignore_missing: bool = False
|
||||||
|
) -> None:
|
||||||
if CONF_SUBSTITUTIONS not in config and not command_line_substitutions:
|
if CONF_SUBSTITUTIONS not in config and not command_line_substitutions:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from ast import literal_eval
|
from ast import literal_eval
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from itertools import chain, islice
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
from types import GeneratorType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import jinja2 as jinja
|
import jinja2 as jinja
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
from jinja2.nativetypes import NativeCodeGenerator, NativeTemplate
|
||||||
|
|
||||||
from esphome.yaml_util import ESPLiteralValue
|
from esphome.yaml_util import ESPLiteralValue
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ detect_jinja_re = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_jinja(st):
|
def has_jinja(st: str) -> bool:
|
||||||
return detect_jinja_re.search(st) is not None
|
return detect_jinja_re.search(st) is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -109,12 +113,56 @@ class TrackerContext(jinja.runtime.Context):
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
class Jinja(SandboxedEnvironment):
|
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])
|
||||||
|
|
||||||
|
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)
|
||||||
|
if not isinstance(result, str):
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (ValueError, SyntaxError, MemoryError, TypeError):
|
||||||
|
pass
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
class Jinja(jinja.Environment):
|
||||||
"""
|
"""
|
||||||
Wraps a Jinja environment
|
Wraps a Jinja environment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, context_vars):
|
# jinja environment customization overrides
|
||||||
|
code_generator_class = NativeCodeGenerator
|
||||||
|
concat = staticmethod(_concat_nodes_override)
|
||||||
|
|
||||||
|
def __init__(self, context_vars: dict):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
trim_blocks=True,
|
trim_blocks=True,
|
||||||
lstrip_blocks=True,
|
lstrip_blocks=True,
|
||||||
@@ -142,19 +190,10 @@ class Jinja(SandboxedEnvironment):
|
|||||||
**SAFE_GLOBALS,
|
**SAFE_GLOBALS,
|
||||||
}
|
}
|
||||||
|
|
||||||
def safe_eval(self, expr):
|
def expand(self, content_str: str | JinjaStr) -> Any:
|
||||||
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
|
Renders a string that may contain Jinja expressions or statements
|
||||||
Returns the resulting processed string if all values could be resolved.
|
Returns the resulting value if all variables and expressions could be resolved.
|
||||||
Otherwise, it returns a tagged (JinjaStr) string that captures variables
|
Otherwise, it returns a tagged (JinjaStr) string that captures variables
|
||||||
in scope (upvalues), like a closure for later evaluation.
|
in scope (upvalues), like a closure for later evaluation.
|
||||||
"""
|
"""
|
||||||
@@ -172,7 +211,7 @@ class Jinja(SandboxedEnvironment):
|
|||||||
self.context_trace = {}
|
self.context_trace = {}
|
||||||
try:
|
try:
|
||||||
template = self.from_string(content_str)
|
template = self.from_string(content_str)
|
||||||
result = self.safe_eval(template.render(override_vars))
|
result = template.render(override_vars)
|
||||||
if isinstance(result, Undefined):
|
if isinstance(result, Undefined):
|
||||||
print("" + result) # force a UndefinedError exception
|
print("" + result) # force a UndefinedError exception
|
||||||
except (TemplateSyntaxError, UndefinedError) as err:
|
except (TemplateSyntaxError, UndefinedError) as err:
|
||||||
@@ -201,3 +240,10 @@ class Jinja(SandboxedEnvironment):
|
|||||||
content_str.result = result
|
content_str.result = result
|
||||||
|
|
||||||
return result, None
|
return result, None
|
||||||
|
|
||||||
|
|
||||||
|
class JinjaTemplate(NativeTemplate):
|
||||||
|
environment_class = Jinja
|
||||||
|
|
||||||
|
|
||||||
|
Jinja.template_class = JinjaTemplate
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from esphome import config as config_module, yaml_util
|
from esphome import config as config_module, yaml_util
|
||||||
from esphome.components import substitutions
|
from esphome.components import substitutions
|
||||||
@@ -60,6 +61,29 @@ def write_yaml(path: Path, data: dict) -> None:
|
|||||||
path.write_text(yaml_util.dump(data), encoding="utf-8")
|
path.write_text(yaml_util.dump(data), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_database(value: Any, path: str = "") -> str | None:
|
||||||
|
if isinstance(value, list):
|
||||||
|
for i, v in enumerate(value):
|
||||||
|
result = verify_database(v, f"{path}[{i}]")
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for k, v in value.items():
|
||||||
|
key_result = verify_database(k, f"{path}/{k}")
|
||||||
|
if key_result is not None:
|
||||||
|
return key_result
|
||||||
|
value_result = verify_database(v, f"{path}/{k}")
|
||||||
|
if value_result is not None:
|
||||||
|
return value_result
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
if not isinstance(value, yaml_util.ESPHomeDataBase):
|
||||||
|
return f"{path}: {value!r} is not ESPHomeDataBase"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def test_substitutions_fixtures(fixture_path):
|
def test_substitutions_fixtures(fixture_path):
|
||||||
base_dir = fixture_path / "substitutions"
|
base_dir = fixture_path / "substitutions"
|
||||||
sources = sorted(glob.glob(str(base_dir / "*.input.yaml")))
|
sources = sorted(glob.glob(str(base_dir / "*.input.yaml")))
|
||||||
@@ -83,6 +107,9 @@ def test_substitutions_fixtures(fixture_path):
|
|||||||
substitutions.do_substitution_pass(config, None)
|
substitutions.do_substitution_pass(config, None)
|
||||||
|
|
||||||
resolve_extend_remove(config)
|
resolve_extend_remove(config)
|
||||||
|
verify_database_result = verify_database(config)
|
||||||
|
if verify_database_result is not None:
|
||||||
|
raise AssertionError(verify_database_result)
|
||||||
|
|
||||||
# Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE
|
# Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE
|
||||||
if expected_path.is_file():
|
if expected_path.is_file():
|
||||||
|
|||||||
Reference in New Issue
Block a user