mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 11:07:33 +00:00
[config] Add --no-defaults flag to config command (#16718)
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user