[core] Only snapshot the user config when esphome config --no-defaults asks for it (#18113)

This commit is contained in:
J. Nick Koston
2026-08-05 21:20:52 -05:00
committed by GitHub
parent 3b6f45894b
commit eb0fac9cbf
4 changed files with 83 additions and 13 deletions
+2
View File
@@ -2601,6 +2601,8 @@ def run_esphome(argv):
config = read_config(
command_line_substitutions,
skip_external_update=skip_external,
# Snapshot only needed by `esphome config --no-defaults`.
snapshot_user_config=getattr(args, "no_defaults", False),
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
+33 -11
View File
@@ -1108,6 +1108,7 @@ def validate_config(
config: dict[str, Any],
command_line_substitutions: dict[str, Any] | None,
skip_external_update: bool = False,
snapshot_user_config: bool = False,
) -> Config:
result = Config()
@@ -1218,11 +1219,13 @@ def validate_config(
# Snapshot the user's config before any schema validation defaults are
# applied. preload_core_config and later validation steps rewrite entries
# in-place with defaulted values; deep-copying here preserves the
# user-supplied keys for `esphome config --no-defaults`.
result.user_config = copy.deepcopy(config)
if substitutions is not None:
result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions)
result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False)
# user-supplied keys for `esphome config --no-defaults`. The deep copy is
# expensive, so it is only taken when that command actually asked for it.
if snapshot_user_config:
result.user_config = copy.deepcopy(config)
if substitutions is not None:
result.user_config[CONF_SUBSTITUTIONS] = copy.deepcopy(substitutions)
result.user_config.move_to_end(CONF_SUBSTITUTIONS, last=False)
# 2. Load partial core config
import esphome.core.config as core_config
@@ -1335,7 +1338,9 @@ class InvalidYAMLError(EsphomeError):
def _load_config(
command_line_substitutions: dict[str, Any], skip_external_update: bool = False
command_line_substitutions: dict[str, Any],
skip_external_update: bool = False,
snapshot_user_config: bool = False,
) -> Config:
"""Load the configuration file."""
try:
@@ -1344,7 +1349,12 @@ def _load_config(
raise InvalidYAMLError(e) from e
try:
return validate_config(config, command_line_substitutions, skip_external_update)
return validate_config(
config,
command_line_substitutions,
skip_external_update=skip_external_update,
snapshot_user_config=snapshot_user_config,
)
except EsphomeError:
raise
except Exception:
@@ -1353,10 +1363,16 @@ def _load_config(
def load_config(
command_line_substitutions: dict[str, Any], skip_external_update: bool = False
command_line_substitutions: dict[str, Any],
skip_external_update: bool = False,
snapshot_user_config: bool = False,
) -> Config:
try:
return _load_config(command_line_substitutions, skip_external_update)
return _load_config(
command_line_substitutions,
skip_external_update=skip_external_update,
snapshot_user_config=snapshot_user_config,
)
except vol.Invalid as err:
raise EsphomeError(f"Error while parsing config: {err}") from err
@@ -1497,11 +1513,17 @@ def strip_default_ids(config):
def read_config(
command_line_substitutions: dict[str, Any], skip_external_update: bool = False
command_line_substitutions: dict[str, Any],
skip_external_update: bool = False,
snapshot_user_config: bool = False,
) -> Config | None:
_LOGGER.info("Reading configuration %s...", CORE.config_path)
try:
res = load_config(command_line_substitutions, skip_external_update)
res = load_config(
command_line_substitutions,
skip_external_update=skip_external_update,
snapshot_user_config=snapshot_user_config,
)
except EsphomeError as err:
_LOGGER.error("Error while reading config: %s", err)
return None
+20
View File
@@ -6362,6 +6362,26 @@ def test_run_esphome_skip_external_update_per_command(
assert mock_read.call_args.kwargs["skip_external_update"] is expected_skip
@pytest.mark.parametrize(
("argv_extra", "expected"),
[(["--no-defaults"], True), ([], False)],
)
def test_run_esphome_snapshot_user_config_only_for_no_defaults(
tmp_path: Path, argv_extra: list[str], expected: bool
) -> None:
"""read_config is invoked with snapshot_user_config=True only when the
config command is run with --no-defaults; otherwise the expensive deep
copy is skipped."""
yaml_file = tmp_path / "device.yaml"
yaml_file.write_text("esphome:\n name: test\n")
with patch("esphome.config.read_config", return_value=None) as mock_read:
run_esphome(["esphome", "config", str(yaml_file), *argv_extra])
mock_read.assert_called_once()
assert mock_read.call_args.kwargs["snapshot_user_config"] is expected
def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None:
"""Test reading XTAL_FREQ from sdkconfig."""
CORE.name = "test-device"
+28 -2
View File
@@ -370,7 +370,7 @@ def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None:
"""
test_config = _get_test_minimal_valid_config(tmp_path)
result = config_module.validate_config(test_config, None)
result = config_module.validate_config(test_config, None, snapshot_user_config=True)
# Snapshot is populated.
assert result.user_config is not None
@@ -393,7 +393,7 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No
"""
test_config = _get_test_minimal_valid_config(tmp_path)
result = config_module.validate_config(test_config, None)
result = config_module.validate_config(test_config, None, snapshot_user_config=True)
assert result.user_config is not None
# preload_core_config injected build_path onto the validated config.
@@ -404,6 +404,32 @@ def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> No
assert result["esphome"] is not result.user_config["esphome"]
def test_validate_config_snapshot_without_substitutions(tmp_path: Path) -> None:
"""The snapshot works for configs that have no substitutions block."""
test_config = _get_test_minimal_valid_config(tmp_path)
del test_config[CONF_SUBSTITUTIONS]
result = config_module.validate_config(test_config, None, snapshot_user_config=True)
assert result.user_config is not None
assert CONF_SUBSTITUTIONS not in result.user_config
assert result.user_config["esphome"] == {"name": "test_device"}
def test_validate_config_skips_user_config_snapshot_by_default(
tmp_path: Path,
) -> None:
"""Without ``snapshot_user_config`` the deep copy is skipped entirely;
only ``esphome config --no-defaults`` needs the snapshot and the copy is
too expensive to take on every load.
"""
test_config = _get_test_minimal_valid_config(tmp_path)
result = config_module.validate_config(test_config, None)
assert result.user_config is None
def test_merge_config_preserves_ordered_dict() -> None:
"""Test that merge_config preserves OrderedDict type.