[packages] Make package !include vars visible to its substitutions block (#16274)

This commit is contained in:
J. Nick Koston
2026-05-06 09:53:14 -05:00
committed by GitHub
parent 545ee03f42
commit 6e1a59da3e
4 changed files with 75 additions and 10 deletions
+49 -10
View File
@@ -414,25 +414,39 @@ def _substitute_package_definition(
def _update_substitutions_context(
parent_context: UserDict,
package_substitutions: dict[str, Any],
eval_context: ContextVars | None = None,
) -> None:
"""Resolve and add new substitutions to the parent context.
Skips keys already present (higher-priority sources win).
String values are substituted against the current context so that
cross-references between substitutions are expanded when possible.
String values are substituted against *eval_context* (or *parent_context*
if not provided) so that cross-references between substitutions are
expanded when possible. Resolved values are written into *parent_context*
and back into *package_substitutions* so that subsequent merges into the
consolidated ``substitutions:`` block carry the resolved value (the
package's ``!include vars`` are no longer in scope after this function
returns).
*eval_context* may layer additional vars (e.g. a package's own ``!include
vars``) on top of *parent_context* so that a package's substitutions can
reference vars passed in by the parent file.
"""
if eval_context is None:
eval_context = ContextVars(parent_context)
for key, value in package_substitutions.items():
if key in parent_context:
continue
if not isinstance(value, str):
parent_context[key] = value
continue
parent_context[key] = substitute(
resolved = substitute(
item=value,
path=[CONF_SUBSTITUTIONS, key],
parent_context=ContextVars(parent_context),
parent_context=eval_context,
strict_undefined=False,
)
parent_context[key] = resolved
package_substitutions[key] = resolved
class _PackageProcessor:
@@ -508,11 +522,36 @@ class _PackageProcessor:
package_config = _process_remote_package(package_config)
return package_config
def collect_substitutions(self, package_config: dict) -> None:
"""Extract substitutions from a package and merge into the shared context."""
def collect_substitutions(
self,
package_config: dict,
context_vars: ContextVars | None,
) -> ContextVars:
"""Extract substitutions from a package and merge into the shared context.
Returns the context updated with the package's ``!include vars`` (or
an equivalent of *context_vars* if the package has none) so the caller
can reuse it when recursing into nested packages. ``None`` inputs are
normalized to an empty :class:`ContextVars`, so the result is always
non-``None``.
"""
# Push the package's own !include vars before evaluating its
# substitutions so they can reference vars passed in by the parent
# (e.g. ``vars: {my_variable: ...}`` on the include entry).
package_context = push_context(
package_config, context_vars if context_vars is not None else ContextVars()
)
if subs := package_config.pop(CONF_SUBSTITUTIONS, {}):
# Resolve before merging so that values referencing the package's
# ``!include vars`` are baked into the consolidated substitutions
# block; once we return, the package vars are no longer in scope.
# ``package_context`` is a ChainMap whose chain already terminates
# in ``self.parent_context`` (set up by ``do_packages_pass``), so
# ``parent_context`` mutations from ``_update_substitutions_context``
# remain visible to evaluation reads.
_update_substitutions_context(self.parent_context, subs, package_context)
self.substitutions.data = merge_config(subs, self.substitutions.data)
_update_substitutions_context(self.parent_context, subs)
return package_context
def process_package(
self,
@@ -525,13 +564,13 @@ class _PackageProcessor:
package_config
)
package_config = self.resolve_package(package_config, context_vars, path)
self.collect_substitutions(package_config)
context_vars = self.collect_substitutions(package_config, context_vars)
if CONF_PACKAGES not in package_config:
return package_config
# Push context from !include vars on the package root and on the packages key
context_vars = push_context(package_config, context_vars)
# Push context from !include vars on the packages key (the package root
# was already pushed in collect_substitutions above).
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
# Disable the deprecated single-package fallback for remote
# packages. _process_remote_package returns dicts with
@@ -0,0 +1,9 @@
binary_sensor:
- platform: template
id: front_door_enrolling
name: Front Door Enrolling
substitutions:
enrolling_id: front_door_enrolling
enrolling_name: Front Door Enrolling
esphome:
name: test
@@ -0,0 +1,9 @@
esphome:
name: test
packages:
fingerprint: !include
file: 18-package_vars_in_subs_inc.yaml
vars:
sensor_name: "Front Door"
sensor_id_prefix: "front_door"
@@ -0,0 +1,8 @@
substitutions:
enrolling_id: ${sensor_id_prefix}_enrolling
enrolling_name: ${sensor_name} Enrolling
binary_sensor:
- platform: template
id: ${enrolling_id}
name: ${enrolling_name}