diff --git a/esphome/__main__.py b/esphome/__main__.py index 7c4028da44..f7d3f8e834 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1428,7 +1428,16 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import yaml_util - if not CORE.verbose: + if getattr(args, "no_defaults", False): + user_config = getattr(config, "user_config", None) + if user_config is None: + _LOGGER.warning( + "--no-defaults requested but the user-only config snapshot is " + "unavailable; falling back to the validated configuration." + ) + else: + config = user_config + elif not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) if not args.show_secrets: @@ -2152,6 +2161,12 @@ def parse_args(argv): parser_config.add_argument( "--show-secrets", help="Show secrets in output.", action="store_true" ) + parser_config.add_argument( + "--no-defaults", + help="Only output the user-supplied configuration without " + "schema defaults applied.", + action="store_true", + ) parser_config_hash = subparsers.add_parser( "config-hash", help="Calculate the hash of the configuration." diff --git a/esphome/config.py b/esphome/config.py index 9da39a387b..91e6df8bad 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc from contextlib import contextmanager import contextvars +import copy import functools import heapq import logging @@ -168,6 +169,11 @@ class Config(OrderedDict, fv.FinalValidateConfig): self.output_paths: list[tuple[ConfigPath, str]] = [] # A list of components ids with the config path self.declare_ids: list[tuple[core.ID, ConfigPath]] = [] + # Snapshot of the user's configuration after substitutions/packages/ + # extend-remove resolution but before any schema validation defaults + # are applied. Populated by validate_config; used by `esphome config + # --no-defaults` to emit only the user-supplied keys. + self.user_config: ConfigType | None = None self._data = {} # Store pending validation tasks (in heap order) self._validation_tasks: list[_ValidationStepTask] = [] @@ -1076,6 +1082,15 @@ def validate_config( ) return result + # 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) + # 2. Load partial core config import esphome.core.config as core_config diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8cce60d351..e99a630e83 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -471,6 +471,88 @@ def test_command_config__show_secrets_skips_redaction( assert "\\033[8m" not in output +def test_command_config__no_defaults_dumps_user_snapshot( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """``--no-defaults`` dumps ``config.user_config`` instead of the + validated config, so schema defaults don't leak into the output.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["esphome"] = {"name": "test", "build_path": "build/test"} + validated["wifi"] = {"ssid": "MyNet", "reboot_timeout": "15min"} + validated.user_config = { + "esphome": {"name": "test"}, + "wifi": {"ssid": "MyNet"}, + } + + result = command_config(args, validated) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + # Defaults present on the validated config must not appear. + assert "reboot_timeout" not in output + assert "build_path" not in output + + +def test_command_config__no_defaults_warns_when_snapshot_missing( + tmp_path: Path, + capfd: CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """If the snapshot is unavailable (e.g. a plain dict was passed in), + ``--no-defaults`` logs a warning and falls back to the input config.""" + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + result = command_config(args, {"wifi": {"ssid": "MyNet"}}) + + assert result == 0 + output = capfd.readouterr().out + assert "ssid: MyNet" in output + assert any( + "user-only config snapshot is unavailable" in rec.message + for rec in caplog.records + ) + + +def test_command_config__no_defaults_skips_strip_default_ids( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """When ``--no-defaults`` is set, ``strip_default_ids`` isn't run -- + the user snapshot is already free of schema-injected IDs.""" + from esphome.config import Config + + setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}}) + args = MockArgs() + args.show_secrets = True + args.no_defaults = True + + validated = Config() + validated["sensor"] = [{"name": "x", "id": "auto_generated"}] + validated.user_config = {"sensor": [{"name": "x"}]} + + with patch( + "esphome.__main__.strip_default_ids", side_effect=AssertionError + ) as mock_strip: + result = command_config(args, validated) + + assert result == 0 + mock_strip.assert_not_called() + output = capfd.readouterr().out + assert "name: x" in output + assert "auto_generated" not in output + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index b5816f742e..baaa99f2a7 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -361,6 +361,47 @@ def test_validate_config_without_command_line_substitutions_maintains_ordered_di assert result[CONF_SUBSTITUTIONS]["var2"] == "value2" +def test_validate_config_captures_user_config_snapshot(tmp_path: Path) -> None: + """validate_config stores a deep copy of the user's config -- with + substitutions re-added and no schema defaults applied -- on + ``result.user_config`` for ``esphome config --no-defaults``. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + # Snapshot is populated. + assert result.user_config is not None + # Substitutions are re-added and appear first. + assert list(result.user_config.keys())[0] == CONF_SUBSTITUTIONS + assert result.user_config[CONF_SUBSTITUTIONS]["var1"] == "value1" + # User-supplied keys are present without schema-default fields like + # ``build_path`` (which preload_core_config injects on the validated + # result's esphome section). + assert result.user_config["esphome"] == {"name": "test_device"} + assert "build_path" not in result.user_config["esphome"] + assert "min_version" not in result.user_config["esphome"] + assert result.user_config["esp32"] == {"board": "esp32dev"} + + +def test_validate_config_user_config_snapshot_is_deep_copy(tmp_path: Path) -> None: + """The snapshot is independent of subsequent mutations to the result + config -- preload_core_config rewrites ``esphome:`` in place, but the + snapshot keeps the user's literal block. + """ + test_config = _get_test_minimal_valid_config(tmp_path) + + result = config_module.validate_config(test_config, None) + + assert result.user_config is not None + # preload_core_config injected build_path onto the validated config. + assert "build_path" in result["esphome"] + # The snapshot was taken before that and is unaffected. + assert "build_path" not in result.user_config["esphome"] + # And the two are not aliased. + assert result["esphome"] is not result.user_config["esphome"] + + def test_merge_config_preserves_ordered_dict() -> None: """Test that merge_config preserves OrderedDict type.