Auto-discovery slug must match Home Assistant's real slugify (P2 fix) (#181)

This commit was merged in pull request #181.
This commit is contained in:
2026-09-23 12:21:47 -04:00
parent 0354db82ce
commit 77d1c0feab
5 changed files with 151 additions and 23 deletions
+18 -21
View File
@@ -21,8 +21,6 @@ missed instances, and ``admin_instances`` includes them (D-009).
""" """
from __future__ import annotations from __future__ import annotations
import re
import unicodedata
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import Any from typing import Any
@@ -34,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN from .const import DOMAIN
from .coordinator import ( from .coordinator import (
@@ -50,9 +49,12 @@ from .core.snapshot import instance_payload
#: sensors reuse the same helper. #: sensors reuse the same helper.
MAX_STATE_LENGTH = 255 MAX_STATE_LENGTH = 255
#: Entity-id length cap the slug mirror applies: HA truncates the #: Entity-id length cap the roster applies to HA's slugify: HA's
#: entity_id (``sensor.`` + slug) at MAX_LENGTH_STATE_ENTITY_ID (64), #: slugifier itself does NOT truncate, but the entity registry derives
#: so the slugged object id alone may not exceed 64 - len("sensor."). #: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
#: entities the slugged object id alone may not exceed
#: 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.") _MAX_SLUG_LENGTH = 64 - len("sensor.")
@@ -108,23 +110,18 @@ def _next_open_quest(
def _slugify(text: str) -> str: def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids. """Slugify a display name the way Home Assistant derives entity ids.
Mirrors ``homeassistant.util.slugify`` (the unicode path HA runs Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
entity names through) for accented-Latin names: NFKD-normalize, entity names through, which transliterates every script to ASCII
fold to ASCII (accents drop out with their combining marks), through unidecode (so non-Latin names survive: ``京子`` slugs to
lowercase, and turn every run of non-alphanumerics into one ``jing_zi``) before folding to lowercase and turning every run of
underscore, capped at HA's entity-id length like the registry does. non-alphanumerics into one underscore, answering HA's ``unknown``
The documented divergence from HA's vendored slugifier is unidecode: sentinel when a name slugs to nothing — then caps the result at
HA transliterates any script to ASCII (so e.g. non-Latin names the object-id length the entity registry enforces (the registry
survive), this mirror drops those characters instead, and a name truncates the full ``sensor.`` + slug entity_id at 64 characters,
that then slugs to nothing falls through to HA's ``unknown`` so the slug alone is capped like this; HA's slugify does not
sentinel — the same answer HA's wrapper gives. (The harness is truncate by itself).
mock-only — no real HA to import the helper from — and entity ids
are registry identity, so the mirror is deliberate.)
""" """
slug = unicodedata.normalize("NFKD", text) return slugify(text)[:_MAX_SLUG_LENGTH]
slug = slug.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "_", slug.lower()).strip("_")
return (slug or "unknown")[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]: def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
+1
View File
@@ -21,6 +21,7 @@ dev = [
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-asyncio>=0.24", "pytest-asyncio>=0.24",
"pyyaml>=6", "pyyaml>=6",
"unidecode>=1.4.0",
"uvicorn>=0.30", "uvicorn>=0.30",
"voluptuous>=0.14", "voluptuous>=0.14",
] ]
+68
View File
@@ -24,16 +24,20 @@ from __future__ import annotations
import asyncio import asyncio
import importlib.util import importlib.util
import inspect import inspect
import re
import sys import sys
import tempfile import tempfile
import unicodedata
import weakref import weakref
from dataclasses import dataclass from dataclasses import dataclass
from html.entities import name2codepoint
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx import httpx
import pytest import pytest
from unidecode import unidecode as _unidecode
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
@@ -46,6 +50,7 @@ _HA_MODULES = (
"homeassistant.config_entries", "homeassistant.config_entries",
"homeassistant.data_entry_flow", "homeassistant.data_entry_flow",
"homeassistant.exceptions", "homeassistant.exceptions",
"homeassistant.util",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.config_validation", "homeassistant.helpers.config_validation",
"homeassistant.helpers.entity", "homeassistant.helpers.entity",
@@ -94,6 +99,7 @@ for _parent, _child in (
("homeassistant", "data_entry_flow"), ("homeassistant", "data_entry_flow"),
("homeassistant", "core"), ("homeassistant", "core"),
("homeassistant", "exceptions"), ("homeassistant", "exceptions"),
("homeassistant", "util"),
("homeassistant", "helpers"), ("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"), ("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "entity"), ("homeassistant.helpers", "entity"),
@@ -218,6 +224,68 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_exceptions_mock.Unauthorized = Unauthorized _exceptions_mock.Unauthorized = Unauthorized
# ---------------------------------------------------------------------------
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
#
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
# and strip dashes, then map dashes to the requested separator — and was
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
# CJK and Greek household-name shapes. Transliteration is the point: HA
# derives the per-child entity ids from a name like "京子" as
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
# never dropping characters into "unknown". Deliberately NOT truncating:
# real slugify caps nothing; the entity registry truncates the full
# entity_id instead.
# ---------------------------------------------------------------------------
_HA_QUOTE_PATTERN = re.compile(r"[']+")
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
if text == "" or text is None:
return ""
text = str(text)
text = _HA_QUOTE_PATTERN.sub("-", text)
text = unicodedata.normalize("NFKD", text)
text = _unidecode(text)
text = _HA_CHAR_ENTITY_PATTERN.sub(
lambda match: chr(name2codepoint[match.group(1)]), text
)
try:
text = _HA_DECIMAL_PATTERN.sub(
lambda match: chr(int(match.group(1))), text
)
except (ValueError, OverflowError):
pass
try:
text = _HA_HEX_PATTERN.sub(
lambda match: chr(int(match.group(1), 16)), text
)
except (ValueError, OverflowError):
pass
text = unicodedata.normalize("NFKD", text)
text = text.lower()
text = _HA_QUOTE_PATTERN.sub("", text)
text = _HA_NUMBERS_PATTERN.sub("", text)
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
return "unknown" if text == "" else text.replace("-", separator)
_ha_mock("homeassistant.util").slugify = _ha_slugify
@dataclass @dataclass
class DeviceInfo: class DeviceInfo:
"""Stand-in for homeassistant.helpers.entity.DeviceInfo. """Stand-in for homeassistant.helpers.entity.DeviceInfo.
+53 -2
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import datetime import datetime
import pytest import pytest
from homeassistant.util import slugify
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
@@ -637,8 +638,9 @@ async def test_empty_household_publishes_an_empty_roster(
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None: def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
"""The slug mirror matches homeassistant.util.slugify for the names """The roster slugs match homeassistant.util.slugify — the real
children are registered with (verified against HA's own slugify).""" slugifier HA derives entity ids from, composed with the entity-id
length cap the entity registry applies to ``sensor.`` ids."""
assert _slugify("Ada") == "ada" assert _slugify("Ada") == "ada"
assert _slugify("Mary Jane") == "mary_jane" assert _slugify("Mary Jane") == "mary_jane"
assert _slugify("Mary-Jane") == "mary_jane" assert _slugify("Mary-Jane") == "mary_jane"
@@ -652,3 +654,52 @@ def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
# The registry caps the entity_id at 64 characters, so the slugged # The registry caps the entity_id at 64 characters, so the slugged
# object id alone is capped at 64 - len("sensor."). # object id alone is capped at 64 - len("sensor.").
assert _slugify("a" * 80) == "a" * (64 - len("sensor.")) assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
def test_slugify_transliterates_non_latin_names_like_home_assistant() -> None:
"""HA's slugifier transliterates every script through unidecode,
so a non-Latin child name slugs to its romanization — never to the
``unknown`` sentinel — and the roster publishes the same slug HA's
entity-id derivation produces for that name."""
assert _slugify("Дана") == "dana"
assert _slugify("京子") == "jing_zi"
assert _slugify("Zoë") == "zoe"
async def test_non_latin_child_names_resolve_auto_discovery(
hass, make_entry
) -> None:
"""A non-Latin child name slugs through the real slugifier, so the
roster slug IS the object id of the per-child sensor's entity_id
and the cards' auto-discovery resolves that child.
HA derives the per-child entity id from the entity NAME ("NestQuest
京子 quests due today") with the same slugifier the roster uses, so
the two must agree exactly; the reviewed mirror dropped non-Latin
characters (slugging "京子" to "unknown"), so the cards built
"sensor.nestquest_unknown_…" ids that no entity had."""
payload = _payload(
[
_child(ADA, "Дана", [_instance(7, ADA)]),
_child(BO, "京子", [_instance(8, BO)]),
]
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
roster = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes["child_roster"]
assert roster == [
{"child_id": ADA, "name": "Дана", "slug": "dana"},
{"child_id": BO, "name": "京子", "slug": "jing_zi"},
]
for roster_entry in roster:
due_entity = _sensor(hass, roster_entry["child_id"], "quests_due_today")
assert due_entity.name == (
f"NestQuest {roster_entry['name']} quests due today"
)
# The entity id HA's registry derives for this entity (from the
# same slugifier) embeds exactly the slug the roster publishes:
# the card builds sensor.nestquest_<slug>_quests_due_today.
assert slugify(due_entity.name) == (
f"nestquest_{roster_entry['slug']}_quests_due_today"
)
Generated
+11
View File
@@ -385,6 +385,7 @@ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "unidecode" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "voluptuous" }, { name = "voluptuous" },
] ]
@@ -400,6 +401,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0.0" }, { name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" }, { name = "pyyaml", specifier = ">=6" },
{ name = "unidecode", specifier = ">=1.4.0" },
{ name = "uvicorn", specifier = ">=0.30" }, { name = "uvicorn", specifier = ">=0.30" },
{ name = "voluptuous", specifier = ">=0.14" }, { name = "voluptuous", specifier = ">=0.14" },
] ]
@@ -689,6 +691,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
] ]
[[package]]
name = "unidecode"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.53.0" version = "0.53.0"