mirror of
https://github.com/esphome/esphome.git
synced 2026-05-10 05:37:55 +08:00
[packages] Add resolve_packages single-call seam (#16235)
This commit is contained in:
@@ -611,3 +611,62 @@ def merge_packages(config: dict) -> dict:
|
||||
config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
|
||||
del config[CONF_PACKAGES]
|
||||
return config
|
||||
|
||||
|
||||
def resolve_packages(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
command_line_substitutions: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load and merge ``packages:`` in one call; return the flattened config.
|
||||
|
||||
Convenience wrapper around :func:`do_packages_pass` followed by
|
||||
:func:`merge_packages`. External tools that want the package-
|
||||
merged dict (without going through full schema validation via
|
||||
:func:`esphome.config.read_config`) get one stable seam to call
|
||||
instead of having to chain the two functions and stay in sync
|
||||
with the pipeline order.
|
||||
|
||||
Note: the full :func:`esphome.config.validate_config` pipeline
|
||||
runs two extra passes around the merge that this wrapper
|
||||
deliberately skips:
|
||||
|
||||
1. :func:`esphome.components.substitutions.do_substitution_pass`
|
||||
runs BETWEEN :func:`do_packages_pass` and
|
||||
:func:`merge_packages`, so ``${var}`` placeholders inside
|
||||
package content are NOT resolved here. Callers that need
|
||||
substitution should invoke ``do_substitution_pass``
|
||||
themselves between calls, or go through the full
|
||||
``validate_config``.
|
||||
2. :func:`esphome.config.resolve_extend_remove` runs AFTER
|
||||
:func:`merge_packages`, so top-level ``!remove`` / ``!extend``
|
||||
markers are NOT applied here. A package-contributed block
|
||||
paired with a top-level ``key: !remove`` will still appear
|
||||
in the returned dict (the marker just sits next to it).
|
||||
|
||||
The wrapper exists for the "what blocks did packages
|
||||
contribute?" question — metadata callers that just need to
|
||||
see merged top-level keys. It is NOT a stand-in for
|
||||
:func:`esphome.config.validate_config` and the two passes
|
||||
above are the reasons why.
|
||||
|
||||
Used by:
|
||||
|
||||
- ``esphome/device-builder`` — the new WebSocket dashboard
|
||||
backend reads device metadata (api / wifi / target-platform
|
||||
flags) off the merged config so packages contribute the same
|
||||
blocks the compiler sees, not just whatever sits at the top
|
||||
of the user's YAML. See
|
||||
https://github.com/esphome/device-builder/issues/288 for the
|
||||
bug this fixes.
|
||||
|
||||
Returns *config* unchanged when ``packages:`` isn't present, so
|
||||
callers can apply this unconditionally without having to peek
|
||||
at the config first.
|
||||
"""
|
||||
if CONF_PACKAGES not in config:
|
||||
return config
|
||||
config = do_packages_pass(
|
||||
config, command_line_substitutions=command_line_substitutions
|
||||
)
|
||||
return merge_packages(config)
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.components.packages import (
|
||||
do_packages_pass,
|
||||
is_package_definition,
|
||||
merge_packages,
|
||||
resolve_packages,
|
||||
)
|
||||
from esphome.components.substitutions import ContextVars, do_substitution_pass
|
||||
import esphome.config as config_module
|
||||
@@ -1621,3 +1622,122 @@ def test_remote_package_vars_resolved_against_sibling_package_substitutions(
|
||||
actual = packages_pass(config)
|
||||
|
||||
assert actual[CONF_SENSOR][0]["pin"] == "GPIO5"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_packages — single-call wrapper around do_packages_pass + merge_packages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_packages_returns_config_unchanged_without_packages() -> None:
|
||||
"""No ``packages:`` key → no-op, same dict back."""
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test"}, CONF_WIFI: {CONF_SSID: "x"}}
|
||||
result = resolve_packages(config)
|
||||
assert result is config
|
||||
assert CONF_PACKAGES not in result
|
||||
|
||||
|
||||
def test_resolve_packages_loads_and_merges_in_one_call() -> None:
|
||||
"""End-to-end: a config with one local-dict package gets its blocks flattened."""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "main"},
|
||||
CONF_PACKAGES: {
|
||||
"shared": {
|
||||
CONF_WIFI: {CONF_SSID: "from_package"},
|
||||
CONF_SENSOR: [
|
||||
{CONF_PLATFORM: "template", CONF_NAME: "from_package_sensor"},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
result = resolve_packages(config)
|
||||
# ``packages:`` is gone — it was consumed by the merge.
|
||||
assert CONF_PACKAGES not in result
|
||||
# Blocks contributed by the package are now top-level.
|
||||
assert result[CONF_WIFI][CONF_SSID] == "from_package"
|
||||
assert result[CONF_SENSOR][0][CONF_NAME] == "from_package_sensor"
|
||||
# The main config's own keys survive untouched.
|
||||
assert result[CONF_ESPHOME][CONF_NAME] == "main"
|
||||
|
||||
|
||||
def test_resolve_packages_preserves_main_config_overrides() -> None:
|
||||
"""Main-config values win over package values for the same key.
|
||||
|
||||
Pinning the precedence ESPHome's compiler uses so any future
|
||||
refactor of the wrapper doesn't accidentally flip the order.
|
||||
"""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "main"},
|
||||
CONF_WIFI: {CONF_SSID: "main_wins"},
|
||||
CONF_PACKAGES: {
|
||||
"shared": {CONF_WIFI: {CONF_SSID: "package_loses"}},
|
||||
},
|
||||
}
|
||||
result = resolve_packages(config)
|
||||
assert result[CONF_WIFI][CONF_SSID] == "main_wins"
|
||||
|
||||
|
||||
def test_resolve_packages_forwards_command_line_substitutions() -> None:
|
||||
"""``command_line_substitutions`` reaches the underlying ``do_packages_pass``.
|
||||
|
||||
The wrapper exists so external tools have one stable seam; if
|
||||
that seam silently dropped a kwarg the underlying call accepts,
|
||||
callers would see surprising behaviour. This pins the
|
||||
pass-through.
|
||||
"""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "main"},
|
||||
CONF_PACKAGES: {"shared": {CONF_WIFI: {CONF_SSID: "from_package"}}},
|
||||
}
|
||||
with patch(
|
||||
"esphome.components.packages.do_packages_pass",
|
||||
wraps=do_packages_pass,
|
||||
) as spy:
|
||||
resolve_packages(config, command_line_substitutions={"foo": "bar"})
|
||||
spy.assert_called_once()
|
||||
_, kwargs = spy.call_args
|
||||
assert kwargs.get("command_line_substitutions") == {"foo": "bar"}
|
||||
|
||||
|
||||
def test_resolve_packages_does_not_run_substitutions() -> None:
|
||||
"""``${var}`` placeholders inside package content stay literal.
|
||||
|
||||
The full ``validate_config`` pipeline runs ``do_substitution_pass``
|
||||
BETWEEN ``do_packages_pass`` and ``merge_packages``; this wrapper
|
||||
skips it on purpose. Pin that contract so a future refactor can't
|
||||
silently start resolving substitutions and break callers that
|
||||
deliberately compose the passes themselves.
|
||||
"""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "main"},
|
||||
CONF_SUBSTITUTIONS: {"ssid_value": "resolved_ssid"},
|
||||
CONF_PACKAGES: {
|
||||
"shared": {CONF_WIFI: {CONF_SSID: "${ssid_value}"}},
|
||||
},
|
||||
}
|
||||
result = resolve_packages(config)
|
||||
# Without ``do_substitution_pass`` the placeholder is preserved.
|
||||
assert result[CONF_WIFI][CONF_SSID] == "${ssid_value}"
|
||||
|
||||
|
||||
def test_resolve_packages_does_not_apply_extend_remove() -> None:
|
||||
"""Top-level ``!remove`` / ``!extend`` markers stay in the merged dict.
|
||||
|
||||
The full ``validate_config`` pipeline runs ``resolve_extend_remove``
|
||||
AFTER ``merge_packages``; this wrapper skips it on purpose. Pin
|
||||
that contract: a package-contributed block paired with a top-level
|
||||
``!remove`` is left as-is for callers to handle (or for them to
|
||||
call ``resolve_extend_remove`` themselves).
|
||||
"""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "main"},
|
||||
CONF_WIFI: Remove(),
|
||||
CONF_PACKAGES: {
|
||||
"shared": {CONF_WIFI: {CONF_SSID: "from_package"}},
|
||||
},
|
||||
}
|
||||
result = resolve_packages(config)
|
||||
# ``merge_packages`` keeps the top-level ``!remove`` (it wins
|
||||
# over the package value during merge), and the marker is not
|
||||
# resolved by this wrapper.
|
||||
assert isinstance(result[CONF_WIFI], Remove)
|
||||
|
||||
Reference in New Issue
Block a user