Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion esphome_device_builder/definitions/components.json

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions script/check_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from esphome_device_builder.controllers.components import ComponentCatalog
from script.sync_components import _FIELD_BULLET_PATTERN

# Per-component shape assertions. Each entry is a tuple of
# ``(component_id, [(field_key, type, required, refs)])``. A field
Expand Down Expand Up @@ -182,6 +183,7 @@ def main() -> int:

failures.extend(_check_option_lists(catalog))
failures.extend(_check_component_gating(catalog))
failures.extend(_check_no_field_bullet_descriptions(catalog))

if failures:
print(f"FAIL: {len(failures)} catalog regression(s):")
Expand Down Expand Up @@ -268,6 +270,25 @@ def _check_option_lists(catalog: ComponentCatalog) -> list[str]:
return failures


def _check_no_field_bullet_descriptions(catalog: ComponentCatalog) -> list[str]:
"""Fail when any component's description matches the field-bullet pattern.

The pattern is imported from ``script/sync_components.py`` so a widening
on the sync side automatically tightens the check side — they cannot
drift apart. Triggered when the upstream esphome-docs schema_doc bug
leaks past ``_repair_field_bullet_descriptions``.
"""
failures: list[str] = []
for component in catalog._by_id.values():
desc = (component.description or "").strip()
if desc and _FIELD_BULLET_PATTERN.match(desc):
failures.append(
f"{component.id}: description is a config-variables bullet "
f"({desc[:80]!r}) — sync workaround missed it"
)
return failures


def _check_component_gating(catalog: ComponentCatalog) -> list[str]:
"""Return a failure message per field missing its ``depends_on_component``."""
failures: list[str] = []
Expand Down
72 changes: 72 additions & 0 deletions script/sync_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,10 @@ def build_catalog(
continue
out.append(entry)

# Workaround for an upstream esphome-docs bug: see
# ``_repair_field_bullet_descriptions``.
_repair_field_bullet_descriptions(out)

# Layer MDX-frontmatter descriptions onto components whose
# schema-supplied description is empty. This patches the upstream
# gap where the prebuilt schema's component index lists per-platform
Expand All @@ -1004,6 +1008,74 @@ def build_catalog(
return out


# Matches a description that is actually the first bullet of an MDX
# ``### Configuration variables`` list — ``- **<key>** (*Optional*):`` or
# the ``*Required*`` variant. Used by ``_repair_field_bullet_descriptions``.
_FIELD_BULLET_PATTERN = re.compile(
r"^-?\s*\*\*[A-Za-z_][\w]*\*\*\s*\(\s*\*(?:Optional|Required)\*\s*\)\s*[:\-]",
)


def _repair_field_bullet_descriptions(entries: list[dict]) -> None:
"""
Repair descriptions baked from a stray first bullet of an MDX list.

Workaround for an upstream bug in ``esphome-docs``'s
``script/schema_doc.py``: when an MDX page documents a platform
component with ``## <Platform>`` -> ``### Configuration variables``
(no prose intro between the two headings -- ``debug.mdx`` is the
canonical example), the generator's ``md_get_paragraph`` skips the
headings but then accepts the first ``- **field** (*Optional*):``
bullet as the paragraph, baking that bullet into the platform
component's ``docs`` field. Affects ``sensor.debug`` /
``text_sensor.debug`` at the time of writing.

For each affected ``<domain>.<stem>`` entry, swap the bullet for the
catalog's own bare-stem entry's description -- that's the umbrella
component the user is actually enabling when picking the entry from
the wizard, and its description is the rich prose intro from the
same MDX file. Skipped when ``<domain>`` is one of the synthetic-
umbrella domains (``_UMBRELLA_ENTRIES`` -- currently ``ota``,
``time``), because in those cases ``<stem>`` is a platform name
rather than a sub-component (``ota.esphome``'s stem ``esphome``
would resolve to the unrelated core ``esphome`` component).
Entries with no usable umbrella are left cleared so the downstream
MDX backfill gets a turn.

Remove this whole function (and the regex above) when the upstream
fix lands and the schema bundle stops emitting these descriptions.
"""
umbrella_domains = {spec["id"] for spec in _UMBRELLA_ENTRIES}
by_id: dict[str, dict] = {e["id"]: e for e in entries}
repaired = 0
cleared = 0
for entry in entries:
desc = (entry.get("description") or "").strip()
if not desc or not _FIELD_BULLET_PATTERN.match(desc):
continue
cid = entry["id"]
umbrella_desc = ""
if "." in cid:
domain, stem = cid.split(".", 1)
if domain not in umbrella_domains:
umbrella = by_id.get(stem)
if umbrella is not None:
umbrella_desc = (umbrella.get("description") or "").strip()
if umbrella_desc:
entry["description"] = umbrella_desc
repaired += 1
else:
entry["description"] = ""
cleared += 1
if repaired or cleared:
_LOGGER.info(
"Repaired %d field-bullet description(s) from umbrella, cleared %d "
"(upstream esphome-docs bug)",
repaired,
cleared,
)


def _backfill_descriptions_from_mdx(entries: list[dict]) -> None:
"""Fill empty names, descriptions and field docs from the docs MDX.

Expand Down
117 changes: 117 additions & 0 deletions tests/test_sync_components_field_bullet_repair.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Contract tests for ``_repair_field_bullet_descriptions``."""

from __future__ import annotations

from script.sync_components import ( # type: ignore[import-not-found]
_repair_field_bullet_descriptions,
)

_UMBRELLA_PROSE = (
"The `debug` component can be used to debug problems with ESPHome. "
"At startup, it prints a bunch of useful information like reset "
"reason, free heap size, ESPHome version and so on."
)


def test_replaces_optional_bullet_with_umbrella_description() -> None:
entries = [
{"id": "debug", "description": _UMBRELLA_PROSE},
{
"id": "sensor.debug",
"description": (
"- **free** (*Optional*): Reports the free heap size in bytes. "
"All options from [Sensor](https://esphome.io/components/sensor)."
),
},
]
_repair_field_bullet_descriptions(entries)
assert entries[1]["description"] == _UMBRELLA_PROSE


def test_replaces_required_bullet_with_umbrella_description() -> None:
entries = [
{"id": "foo", "description": _UMBRELLA_PROSE},
{
"id": "sensor.foo",
"description": "- **bar** (*Required*): something or other.",
},
]
_repair_field_bullet_descriptions(entries)
assert entries[1]["description"] == _UMBRELLA_PROSE


def test_clears_bullet_when_no_umbrella_entry_exists() -> None:
entries = [
{
"id": "sensor.orphan",
"description": "- **baz** (*Optional*): no parent in this catalog.",
},
]
_repair_field_bullet_descriptions(entries)
assert entries[0]["description"] == ""


def test_clears_bullet_when_umbrella_description_is_empty() -> None:
entries = [
{"id": "ghost", "description": ""},
{
"id": "sensor.ghost",
"description": "- **x** (*Optional*): unused.",
},
]
_repair_field_bullet_descriptions(entries)
assert entries[1]["description"] == ""


def test_leaves_normal_descriptions_untouched() -> None:
"""The pass must not touch entries that already have a real description."""
entries = [
{"id": "wifi", "description": "Connects the ESP to WiFi."},
{"id": "sensor.dht", "description": "DHT temperature/humidity sensor."},
{"id": "switch.gpio", "description": ""},
]
_repair_field_bullet_descriptions(entries)
assert entries[0]["description"] == "Connects the ESP to WiFi."
assert entries[1]["description"] == "DHT temperature/humidity sensor."
assert entries[2]["description"] == ""


def test_does_not_match_field_bullets_inside_prose() -> None:
"""A leading ``- **foo**`` is required at the start; mid-string matches don't count."""
desc = (
"DHT temperature/humidity sensor. Fields include - **temperature** "
"(*Optional*) and humidity sub-readings."
)
entries = [
{"id": "dht", "description": _UMBRELLA_PROSE},
{"id": "sensor.dht", "description": desc},
]
_repair_field_bullet_descriptions(entries)
assert entries[1]["description"] == desc


def test_handles_bare_stem_components_with_bullet_description() -> None:
"""A bullet-shaped description on a bare stem (no dot) gets cleared."""
entries = [
{"id": "rare_bug", "description": "- **field** (*Optional*): bad scrape."},
]
_repair_field_bullet_descriptions(entries)
assert entries[0]["description"] == ""


def test_skips_stem_lookup_for_synthetic_umbrella_domains() -> None:
"""Skip stem-based umbrella for ``ota``/``time`` domains -- stem can collide."""
entries = [
# The core ``esphome`` component is a real catalog entry with its
# own description, but it has no relationship to ``ota.esphome``.
# Substituting would land the core config description on an OTA
# platform entry.
{"id": "esphome", "description": "Core ESPHome configuration."},
{
"id": "ota.esphome",
"description": "- **password** (*Optional*): the OTA password.",
},
]
_repair_field_bullet_descriptions(entries)
assert entries[0]["description"] == "Core ESPHome configuration."
assert entries[1]["description"] == ""
Loading