[core] Support YAML frontmatter for arbitrary user metadata (#16552)

This commit is contained in:
Jesse Hills
2026-05-22 13:00:22 +12:00
committed by GitHub
parent 4ff8eb4b15
commit 1d3eea098e
3 changed files with 193 additions and 3 deletions
+8 -1
View File
@@ -5,7 +5,7 @@ import math
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from esphome.const import (
CONF_COMMENT,
@@ -569,6 +569,12 @@ class EsphomeCore:
self.build_path: Path | None = None
# The validated configuration, this is None until the config has been validated
self.config: ConfigType | None = None
# YAML frontmatter loaded from user YAML files. Frontmatter is a leading
# YAML document separated by `---` from the actual configuration. It is
# ignored by config validation and code generation, but kept here so it
# can be inspected by callers (tooling, future features). Keyed by the
# resolved Path of the source file.
self.frontmatter: dict[Path, Any] = {}
# The pending tasks in the task queue (mostly for C++ generation)
# This is a priority queue (with heapq)
# Each item is a tuple of form: (-priority, unique number, task)
@@ -634,6 +640,7 @@ class EsphomeCore:
self.config_path = None
self.build_path = None
self.config = None
self.frontmatter = {}
self.event_loop = _FakeEventLoop()
self.task_counter = 0
self.variables = {}
+27 -2
View File
@@ -768,10 +768,35 @@ def _load_yaml_internal_with_type(
content: TextIOWrapper,
yaml_loader: Callable[[Path], dict[str, Any]],
) -> Any:
"""Load a YAML file."""
"""Load a YAML file.
Supports an optional leading YAML frontmatter document: when the file
contains two YAML documents separated by ``---``, the first document is
treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the
resolved file path, while the second document is returned as the actual
configuration. Frontmatter is ignored by config validation and code
generation.
"""
loader = loader_type(content, fname, yaml_loader)
try:
return loader.get_single_data() or OrderedDict()
documents: list[Any] = []
while loader.check_data():
documents.append(loader.get_data())
if len(documents) > 2:
raise EsphomeError(
f"YAML file '{fname}' contains {len(documents)} documents but "
f"at most two are supported (an optional frontmatter document "
f"followed by the configuration)."
)
if len(documents) == 2:
frontmatter = documents[0]
config = documents[1]
if frontmatter is not None:
CORE.frontmatter[Path(fname).resolve()] = frontmatter
return config if config is not None else OrderedDict()
if len(documents) == 1:
return documents[0] or OrderedDict()
return OrderedDict()
except yaml.YAMLError as exc:
raise EsphomeError(exc) from exc
finally:
+158
View File
@@ -34,6 +34,14 @@ def clear_secrets_cache() -> None:
yaml_util._SECRET_CACHE.clear()
@pytest.fixture(autouse=True)
def clear_core_frontmatter() -> None:
"""Reset CORE.frontmatter between tests."""
core.CORE.frontmatter = {}
yield
core.CORE.frontmatter = {}
def test_include_with_vars(fixture_path: Path) -> None:
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
@@ -1182,3 +1190,153 @@ def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
with track_yaml_loads() as loaded:
yaml_util.load_yaml(link)
assert target.resolve() in loaded
# ---------------------------------------------------------------------------
# YAML frontmatter
# ---------------------------------------------------------------------------
def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None:
"""A leading `---`-separated YAML document is stored as frontmatter and
stripped from the returned config."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text(
"author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n"
)
config = yaml_util.load_yaml(yaml_file)
# Config does not contain frontmatter keys
assert "author" not in config
assert "labels" not in config
assert config["esphome"]["name"] == "my_node"
# Frontmatter is stored on CORE keyed by resolved path
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
assert frontmatter["author"] == "Jesse"
assert frontmatter["labels"] == ["office", "climate"]
def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None:
"""A YAML file with a single document does not populate CORE.frontmatter."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("esphome:\n name: my_node\n")
yaml_util.load_yaml(yaml_file)
assert yaml_file.resolve() not in core.CORE.frontmatter
def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None:
"""A leading `---` with no content above it is just a document start marker,
not frontmatter, and must not populate CORE.frontmatter."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("---\nesphome:\n name: my_node\n")
config = yaml_util.load_yaml(yaml_file)
assert config["esphome"]["name"] == "my_node"
assert yaml_file.resolve() not in core.CORE.frontmatter
def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None:
"""Frontmatter keys are not validated — any structure is accepted."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text(
"any_key: any_value\n"
"nested:\n"
" count: 42\n"
" items:\n"
" - a\n"
" - b\n"
"---\n"
"esphome:\n"
" name: t\n"
)
yaml_util.load_yaml(yaml_file)
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
assert frontmatter["any_key"] == "any_value"
assert frontmatter["nested"]["count"] == 42
assert frontmatter["nested"]["items"] == ["a", "b"]
def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None:
"""Frontmatter preserves deeply nested dict/list structures intact."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text(
"device:\n"
" metadata:\n"
" location:\n"
" building: HQ\n"
" floor: 3\n"
" room:\n"
" number: 302\n"
" occupants:\n"
" - name: Jesse\n"
" role:\n"
" title: maintainer\n"
" since: 2021\n"
" - name: Alice\n"
" role:\n"
" title: contributor\n"
" since: 2024\n"
"---\n"
"esphome:\n"
" name: t\n"
)
yaml_util.load_yaml(yaml_file)
fm = core.CORE.frontmatter[yaml_file.resolve()]
room = fm["device"]["metadata"]["location"]["room"]
assert room["number"] == 302
assert room["occupants"][0]["name"] == "Jesse"
assert room["occupants"][0]["role"]["title"] == "maintainer"
assert room["occupants"][0]["role"]["since"] == 2021
assert room["occupants"][1]["role"]["title"] == "contributor"
def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None:
"""Three or more YAML documents is unsupported and must raise."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n")
with pytest.raises(EsphomeError, match="at most two are supported"):
yaml_util.load_yaml(yaml_file)
def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None:
"""An empty (null) frontmatter document is treated as no frontmatter."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("---\n---\nesphome:\n name: t\n")
config = yaml_util.load_yaml(yaml_file)
assert config["esphome"]["name"] == "t"
assert yaml_file.resolve() not in core.CORE.frontmatter
def test_frontmatter_empty_config_doc(tmp_path: Path) -> None:
"""An empty config document after a frontmatter document yields an empty config."""
yaml_file = tmp_path / "main.yaml"
yaml_file.write_text("only: frontmatter\n---\n")
config = yaml_util.load_yaml(yaml_file)
assert config == {}
assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter"
def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
"""Frontmatter on an !include'd file is also captured on CORE, keyed by
that file's resolved path."""
inc = tmp_path / "child.yaml"
inc.write_text("child_meta: hello\n---\nchild_key: value\n")
main = tmp_path / "main.yaml"
main.write_text("esphome:\n name: t\nchild: !include child.yaml\n")
config = yaml_util.load_yaml(main)
# !include is deferred; force resolution so the child file actually loads
force_load_include_files(config)
assert config["child"].load()["child_key"] == "value"
# Main file has no frontmatter
assert main.resolve() not in core.CORE.frontmatter
# Included file's frontmatter is captured
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"