mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[core] Skip source range tracking when loading the validated config cache (#18046)
This commit is contained in:
@@ -61,7 +61,14 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
|||||||
from esphome import yaml_util
|
from esphome import yaml_util
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
|
# Fast path never validates or generates code - no source ranges
|
||||||
|
# needed (see load_yaml). Callers must not feed this config into
|
||||||
|
# read_config/write_cpp: the esp_range consumers in config.py and
|
||||||
|
# cpp_generator.py are isinstance-guarded and would degrade
|
||||||
|
# silently (wrong error/lambda locations) instead of raising.
|
||||||
|
config = yaml_util.load_yaml(
|
||||||
|
cache_path, clear_secrets=False, track_document_range=False
|
||||||
|
)
|
||||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+78
-18
@@ -546,6 +546,13 @@ def _add_data_ref(fn):
|
|||||||
# Let generator finish
|
# Let generator finish
|
||||||
for _ in generator:
|
for _ in generator:
|
||||||
pass
|
pass
|
||||||
|
# Fast mode keeps this per-node attribute check instead of a second
|
||||||
|
# constructor table: measured, fast mode already parses within ~8%
|
||||||
|
# of a raw CSafeLoader, so a parallel table isn't worth the
|
||||||
|
# duplication (and undecorated constructors return generators with
|
||||||
|
# different resolution ordering).
|
||||||
|
if not loader.track_document_range:
|
||||||
|
return res
|
||||||
res = make_data_base(res)
|
res = make_data_base(res)
|
||||||
if isinstance(res, ESPHomeDataBase):
|
if isinstance(res, ESPHomeDataBase):
|
||||||
res.from_node(node)
|
res.from_node(node)
|
||||||
@@ -585,14 +592,19 @@ def _resolve_merge_include(value: Any, node: yaml.Node, value_node: yaml.Node) -
|
|||||||
|
|
||||||
|
|
||||||
class ESPHomeLoaderMixin:
|
class ESPHomeLoaderMixin:
|
||||||
"""Loader class that keeps track of line numbers."""
|
"""Loader that tracks line numbers unless track_document_range is off."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, name: Path, yaml_loader: Callable[[Path], dict[str, Any]]
|
self,
|
||||||
|
name: Path,
|
||||||
|
yaml_loader: Callable[[Path], dict[str, Any]],
|
||||||
|
*,
|
||||||
|
track_document_range: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the loader."""
|
"""Initialize the loader. See load_yaml for track_document_range."""
|
||||||
self.name = name
|
self.name = name
|
||||||
self.yaml_loader = yaml_loader
|
self.yaml_loader = yaml_loader
|
||||||
|
self.track_document_range = track_document_range
|
||||||
|
|
||||||
@_add_data_ref
|
@_add_data_ref
|
||||||
def construct_yaml_int(self, node):
|
def construct_yaml_int(self, node):
|
||||||
@@ -655,8 +667,10 @@ class ESPHomeLoaderMixin:
|
|||||||
f'Invalid key "{key}" (not hashable)', key_node.start_mark
|
f'Invalid key "{key}" (not hashable)', key_node.start_mark
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
key = make_data_base(str(key))
|
key = str(key)
|
||||||
key.from_node(key_node)
|
if self.track_document_range:
|
||||||
|
key = make_data_base(key)
|
||||||
|
key.from_node(key_node)
|
||||||
|
|
||||||
# Check if it is a duplicate key
|
# Check if it is a duplicate key
|
||||||
if key in seen_keys:
|
if key in seen_keys:
|
||||||
@@ -852,29 +866,37 @@ class ESPHomeLoaderMixin:
|
|||||||
|
|
||||||
|
|
||||||
class ESPHomeLoader(ESPHomeLoaderMixin, FastestAvailableSafeLoader):
|
class ESPHomeLoader(ESPHomeLoaderMixin, FastestAvailableSafeLoader):
|
||||||
"""Loader class that keeps track of line numbers."""
|
"""C-accelerated loader; see ESPHomeLoaderMixin."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stream: TextIOBase | BytesIO,
|
stream: TextIOBase | BytesIO,
|
||||||
name: Path,
|
name: Path,
|
||||||
yaml_loader: Callable[[Path], dict[str, Any]],
|
yaml_loader: Callable[[Path], dict[str, Any]],
|
||||||
|
*,
|
||||||
|
track_document_range: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
FastestAvailableSafeLoader.__init__(self, stream)
|
FastestAvailableSafeLoader.__init__(self, stream)
|
||||||
ESPHomeLoaderMixin.__init__(self, name, yaml_loader)
|
ESPHomeLoaderMixin.__init__(
|
||||||
|
self, name, yaml_loader, track_document_range=track_document_range
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ESPHomePurePythonLoader(ESPHomeLoaderMixin, PurePythonLoader):
|
class ESPHomePurePythonLoader(ESPHomeLoaderMixin, PurePythonLoader):
|
||||||
"""Loader class that keeps track of line numbers."""
|
"""Pure-Python loader with readable errors; see ESPHomeLoaderMixin."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stream: TextIOBase | BytesIO,
|
stream: TextIOBase | BytesIO,
|
||||||
name: Path,
|
name: Path,
|
||||||
yaml_loader: Callable[[Path], dict[str, Any]],
|
yaml_loader: Callable[[Path], dict[str, Any]],
|
||||||
|
*,
|
||||||
|
track_document_range: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
PurePythonLoader.__init__(self, stream)
|
PurePythonLoader.__init__(self, stream)
|
||||||
ESPHomeLoaderMixin.__init__(self, name, yaml_loader)
|
ESPHomeLoaderMixin.__init__(
|
||||||
|
self, name, yaml_loader, track_document_range=track_document_range
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
for _loader in (ESPHomeLoader, ESPHomePurePythonLoader):
|
for _loader in (ESPHomeLoader, ESPHomePurePythonLoader):
|
||||||
@@ -902,20 +924,31 @@ for _loader in (ESPHomeLoader, ESPHomePurePythonLoader):
|
|||||||
_loader.add_constructor("!remove", _loader.construct_remove)
|
_loader.add_constructor("!remove", _loader.construct_remove)
|
||||||
|
|
||||||
|
|
||||||
def load_yaml(fname: Path, clear_secrets: bool = True) -> Any:
|
def load_yaml(
|
||||||
|
fname: Path, clear_secrets: bool = True, *, track_document_range: bool = True
|
||||||
|
) -> Any:
|
||||||
|
"""Load a YAML file.
|
||||||
|
|
||||||
|
track_document_range=False skips wrapping every node in an
|
||||||
|
ESPHomeDataBase subclass carrying its source range. That metadata
|
||||||
|
serves validation error messages and lambda source locations in
|
||||||
|
generated code; callers that neither validate nor generate code (the
|
||||||
|
upload/logs fast path re-reading the validated config cache) can skip
|
||||||
|
it, roughly halving parse time.
|
||||||
|
"""
|
||||||
if clear_secrets:
|
if clear_secrets:
|
||||||
_SECRET_VALUES.clear()
|
_SECRET_VALUES.clear()
|
||||||
_SECRET_CACHE.clear()
|
_SECRET_CACHE.clear()
|
||||||
return _load_yaml_internal(fname)
|
return _load_yaml_internal(fname, track_document_range=track_document_range)
|
||||||
|
|
||||||
|
|
||||||
def _load_yaml_internal(fname: Path) -> Any:
|
def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> Any:
|
||||||
"""Load a YAML file."""
|
"""Load a YAML file."""
|
||||||
for listener in _load_listeners:
|
for listener in _load_listeners:
|
||||||
listener(fname)
|
listener(fname)
|
||||||
try:
|
try:
|
||||||
with fname.open(encoding="utf-8") as f_handle:
|
with fname.open(encoding="utf-8") as f_handle:
|
||||||
res = parse_yaml(fname, f_handle)
|
res = parse_yaml(fname, f_handle, track_document_range=track_document_range)
|
||||||
except (UnicodeDecodeError, OSError) as err:
|
except (UnicodeDecodeError, OSError) as err:
|
||||||
raise EsphomeError(f"Error reading file {fname}: {err}") from err
|
raise EsphomeError(f"Error reading file {fname}: {err}") from err
|
||||||
# Top-level !include returns a deferred IncludeFile; resolve it so
|
# Top-level !include returns a deferred IncludeFile; resolve it so
|
||||||
@@ -925,13 +958,32 @@ def _load_yaml_internal(fname: Path) -> Any:
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) -> Any:
|
_FAST_YAML_LOADER = functools.partial(_load_yaml_internal, track_document_range=False)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yaml(
|
||||||
|
file_name: Path,
|
||||||
|
file_handle: TextIOWrapper,
|
||||||
|
yaml_loader=None,
|
||||||
|
*,
|
||||||
|
track_document_range: bool = True,
|
||||||
|
) -> Any:
|
||||||
"""Parse a YAML file."""
|
"""Parse a YAML file."""
|
||||||
if yaml_loader is None:
|
if yaml_loader is None:
|
||||||
yaml_loader = _load_yaml_internal
|
# Nested loads (!include, !secret, !include_dir_*) inherit the
|
||||||
|
# same tracking mode.
|
||||||
|
yaml_loader = _load_yaml_internal if track_document_range else _FAST_YAML_LOADER
|
||||||
|
elif not track_document_range:
|
||||||
|
# A caller-supplied loader would silently revert nested loads to
|
||||||
|
# tracked mode; reject the combination instead of half-applying it.
|
||||||
|
raise ValueError("track_document_range=False requires the default yaml_loader")
|
||||||
try:
|
try:
|
||||||
return _load_yaml_internal_with_type(
|
return _load_yaml_internal_with_type(
|
||||||
ESPHomeLoader, file_name, file_handle, yaml_loader
|
ESPHomeLoader,
|
||||||
|
file_name,
|
||||||
|
file_handle,
|
||||||
|
yaml_loader,
|
||||||
|
track_document_range=track_document_range,
|
||||||
)
|
)
|
||||||
except EsphomeError:
|
except EsphomeError:
|
||||||
# Loading failed, so we now load with the Python loader which has more
|
# Loading failed, so we now load with the Python loader which has more
|
||||||
@@ -939,7 +991,11 @@ def parse_yaml(file_name: Path, file_handle: TextIOWrapper, yaml_loader=None) ->
|
|||||||
# Rewind the stream so we can try again
|
# Rewind the stream so we can try again
|
||||||
file_handle.seek(0, 0)
|
file_handle.seek(0, 0)
|
||||||
return _load_yaml_internal_with_type(
|
return _load_yaml_internal_with_type(
|
||||||
ESPHomePurePythonLoader, file_name, file_handle, yaml_loader
|
ESPHomePurePythonLoader,
|
||||||
|
file_name,
|
||||||
|
file_handle,
|
||||||
|
yaml_loader,
|
||||||
|
track_document_range=track_document_range,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -948,6 +1004,8 @@ def _load_yaml_internal_with_type(
|
|||||||
fname: Path,
|
fname: Path,
|
||||||
content: TextIOWrapper,
|
content: TextIOWrapper,
|
||||||
yaml_loader: Callable[[Path], dict[str, Any]],
|
yaml_loader: Callable[[Path], dict[str, Any]],
|
||||||
|
*,
|
||||||
|
track_document_range: bool,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Load a YAML file.
|
"""Load a YAML file.
|
||||||
|
|
||||||
@@ -958,7 +1016,9 @@ def _load_yaml_internal_with_type(
|
|||||||
configuration. Frontmatter is ignored by config validation and code
|
configuration. Frontmatter is ignored by config validation and code
|
||||||
generation.
|
generation.
|
||||||
"""
|
"""
|
||||||
loader = loader_type(content, fname, yaml_loader)
|
loader = loader_type(
|
||||||
|
content, fname, yaml_loader, track_document_range=track_document_range
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
documents: list[Any] = []
|
documents: list[Any] = []
|
||||||
while loader.check_data():
|
while loader.check_data():
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from esphome.const import (
|
|||||||
KEY_VARIANT,
|
KEY_VARIANT,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
from esphome.yaml_util import ESPHomeDataBase
|
||||||
|
|
||||||
_VALIDATED_CONFIG_YAML = """\
|
_VALIDATED_CONFIG_YAML = """\
|
||||||
esphome:
|
esphome:
|
||||||
@@ -125,6 +126,10 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
|
|||||||
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
|
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
|
||||||
assert config["ota"][0]["password"] == "secret"
|
assert config["ota"][0]["password"] == "secret"
|
||||||
|
|
||||||
|
# The fast path loads without per-node source ranges (the full
|
||||||
|
# contract lives in test_yaml_util; this checks the flag is wired up).
|
||||||
|
assert not isinstance(config[CONF_ESPHOME][CONF_NAME], ESPHomeDataBase)
|
||||||
|
|
||||||
# apply_to_core populated exactly what upload/logs read off CORE.
|
# apply_to_core populated exactly what upload/logs read off CORE.
|
||||||
assert CORE.name == "lite_test"
|
assert CORE.name == "lite_test"
|
||||||
assert CORE.build_path == Path("/build/lite_test")
|
assert CORE.build_path == Path("/build/lite_test")
|
||||||
|
|||||||
@@ -1780,3 +1780,122 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None:
|
|||||||
assert result["api"] == {"reboot_timeout": "5min"}
|
assert result["api"] == {"reboot_timeout": "5min"}
|
||||||
assert result["logger"] == {"level": "DEBUG"}
|
assert result["logger"] == {"level": "DEBUG"}
|
||||||
assert yaml_util.take_dropped_merge_keys() == []
|
assert yaml_util.take_dropped_merge_keys() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# track_document_range=False (validated-config-cache fast path)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FAST_MODE_MAIN_YAML = """\
|
||||||
|
defaults: &defaults
|
||||||
|
port: 6053
|
||||||
|
reboot_timeout: 15min
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: !secret devname
|
||||||
|
|
||||||
|
api:
|
||||||
|
<<: *defaults
|
||||||
|
port: 6054
|
||||||
|
|
||||||
|
number_value: 42
|
||||||
|
float_value: 3.5
|
||||||
|
lambda_value: !lambda 'return x * 2;'
|
||||||
|
extend_value: !extend some_id
|
||||||
|
remove_value: !remove some_id
|
||||||
|
literal_value: !literal keep_me_verbatim
|
||||||
|
included: !include included.yaml
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fast_mode_config_dir(tmp_path: Path) -> Path:
|
||||||
|
_write(tmp_path, "main.yaml", FAST_MODE_MAIN_YAML)
|
||||||
|
_write(tmp_path, "included.yaml", "inner_key: inner_value\ninner_num: 7\n")
|
||||||
|
_write(tmp_path, "secrets.yaml", "devname: livingroom\n")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_includes(config: dict) -> dict:
|
||||||
|
return {
|
||||||
|
key: value.load() if isinstance(value, yaml_util.IncludeFile) else value
|
||||||
|
for key, value in config.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_fast_mode_matches_default(fast_mode_config_dir: Path) -> None:
|
||||||
|
"""Both modes produce equal values; only the metadata wrapping differs."""
|
||||||
|
yaml_file = fast_mode_config_dir / "main.yaml"
|
||||||
|
|
||||||
|
normal = _resolve_includes(yaml_util.load_yaml(yaml_file))
|
||||||
|
fast = _resolve_includes(yaml_util.load_yaml(yaml_file, track_document_range=False))
|
||||||
|
|
||||||
|
# Lambda has no __eq__; compare it by value and the rest structurally.
|
||||||
|
fast_lambda = fast.pop("lambda_value")
|
||||||
|
normal_lambda = normal.pop("lambda_value")
|
||||||
|
assert fast == normal
|
||||||
|
assert isinstance(fast_lambda, core.Lambda)
|
||||||
|
assert fast_lambda.value == normal_lambda.value == "return x * 2;"
|
||||||
|
assert fast["esphome"]["name"] == "livingroom"
|
||||||
|
assert fast["api"]["port"] == 6054
|
||||||
|
assert fast["api"]["reboot_timeout"] == "15min"
|
||||||
|
assert fast["extend_value"] == Extend("some_id")
|
||||||
|
assert fast["remove_value"] == Remove("some_id")
|
||||||
|
# !literal wraps via make_literal, independent of range tracking.
|
||||||
|
assert isinstance(fast["literal_value"], ESPLiteralValue)
|
||||||
|
assert fast["literal_value"] == "keep_me_verbatim"
|
||||||
|
|
||||||
|
# Fast mode returns plain values; default mode keeps the range metadata.
|
||||||
|
assert not isinstance(fast["number_value"], ESPHomeDataBase)
|
||||||
|
assert not isinstance(fast["float_value"], ESPHomeDataBase)
|
||||||
|
assert all(type(key) is str for key in fast)
|
||||||
|
assert isinstance(normal["number_value"], ESPHomeDataBase)
|
||||||
|
assert normal["number_value"].esp_range is not None
|
||||||
|
assert all(isinstance(key, ESPHomeDataBase) for key in normal)
|
||||||
|
|
||||||
|
# Nested includes inherit fast mode through the recursive loader.
|
||||||
|
included = fast["included"]
|
||||||
|
assert not isinstance(included["inner_num"], ESPHomeDataBase)
|
||||||
|
assert all(type(key) is str for key in included)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_fast_mode_survives_pure_python_fallback(
|
||||||
|
fast_mode_config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The ESPHomePurePythonLoader retry must honour fast mode too."""
|
||||||
|
yaml_file = fast_mode_config_dir / "main.yaml"
|
||||||
|
|
||||||
|
class _AlwaysFailingLoader(yaml_util.ESPHomeLoader):
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
raise EsphomeError("forced fallback to the pure-Python loader")
|
||||||
|
|
||||||
|
with patch.object(yaml_util, "ESPHomeLoader", _AlwaysFailingLoader):
|
||||||
|
fast = yaml_util.load_yaml(yaml_file, track_document_range=False)
|
||||||
|
|
||||||
|
assert not isinstance(fast["number_value"], ESPHomeDataBase)
|
||||||
|
assert all(type(key) is str for key in fast)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_fast_mode_rejects_custom_loader() -> None:
|
||||||
|
"""A caller-supplied yaml_loader cannot combine with fast mode."""
|
||||||
|
with pytest.raises(ValueError, match="default yaml_loader"):
|
||||||
|
yaml_util.parse_yaml(
|
||||||
|
Path("x.yaml"),
|
||||||
|
io.StringIO("a: 1"),
|
||||||
|
lambda f: {},
|
||||||
|
track_document_range=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_fast_mode_records_dropped_merge_keys(
|
||||||
|
fast_mode_config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The duplicate-merge-key bookkeeping must not crash on plain str keys.
|
||||||
|
|
||||||
|
With plain keys there is no esp_range, so the recorded location falls
|
||||||
|
back to the parent file name.
|
||||||
|
"""
|
||||||
|
yaml_file = fast_mode_config_dir / "main.yaml"
|
||||||
|
|
||||||
|
yaml_util.load_yaml(yaml_file, track_document_range=False)
|
||||||
|
assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))]
|
||||||
|
|||||||
Reference in New Issue
Block a user