From 6500632c83e2011d6370ff79d28e8cb45672a0cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2026 19:37:39 -0500 Subject: [PATCH] Skip the user_config deep copy unless esphome config --no-defaults needs it --- esphome/__main__.py | 2 ++ esphome/config.py | 44 +++++++++++++++++++------- tests/unit_tests/test_substitutions.py | 18 +++++++++-- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index f435b18bb3..cb45dd7c5f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -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 diff --git a/esphome/config.py b/esphome/config.py index 976faed447..b747c69b3a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -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 diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index bcaf3fb354..839a475aa2 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -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,20 @@ 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_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.