[packages] Add resolve_packages single-call seam (#16235)

This commit is contained in:
J. Nick Koston
2026-05-05 18:26:52 -05:00
committed by GitHub
parent 39b2b901f7
commit 67491c3194
2 changed files with 179 additions and 0 deletions
+59
View File
@@ -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)