Roster slugs through Home Assistant's real slugify (P2 fix)

The per-child entity ids are derived from the child name by Home
Assistant's real slugifier, which transliterates every script through
unidecode; the hand-rolled Latin-only mirror in sensor.py dropped
non-Latin characters instead (slugging 京子 to "unknown"), so the
roster's slugs diverged from the actual entity ids and the
party-board/strategy auto-discovery silently failed for non-Latin
households.

Use homeassistant.util.slugify at runtime, composing the registry's
64-character entity-id cap the roster needs (verified against HA
2024.6: slugify itself does not truncate; the entity registry
truncates the full <domain>.<slug> id). Give the mock harness a
faithful homeassistant.util.slugify stub — python-slugify's frozen
legacy pipeline with unidecode, verified byte-for-byte against real
python-slugify across Latin, Cyrillic, CJK and Greek name shapes —
and cover the non-Latin resolution contract in tests (roster slug ==
the object id HA's entity-id derivation produces).

Feature-review finding (P2): auto-discovery slug must match Home
Assistant's real slugify.
This commit is contained in:
2026-09-23 12:12:58 -04:00
parent 0354db82ce
commit 2f055f4065
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
import re
import unicodedata
from collections.abc import Callable, Iterable
from typing import Any
@@ -34,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import (
@@ -50,9 +49,12 @@ from .core.snapshot import instance_payload
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the slug mirror applies: HA truncates the
#: entity_id (``sensor.`` + slug) at MAX_LENGTH_STATE_ENTITY_ID (64),
#: so the slugged object id alone may not exceed 64 - len("sensor.").
#: Entity-id length cap the roster applies to HA's slugify: HA's
#: slugifier itself does NOT truncate, but the entity registry derives
#: 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.")
@@ -108,23 +110,18 @@ def _next_open_quest(
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Mirrors ``homeassistant.util.slugify`` (the unicode path HA runs
entity names through) for accented-Latin names: NFKD-normalize,
fold to ASCII (accents drop out with their combining marks),
lowercase, and turn every run of non-alphanumerics into one
underscore, capped at HA's entity-id length like the registry does.
The documented divergence from HA's vendored slugifier is unidecode:
HA transliterates any script to ASCII (so e.g. non-Latin names
survive), this mirror drops those characters instead, and a name
that then slugs to nothing falls through to HA's ``unknown``
sentinel — the same answer HA's wrapper gives. (The harness is
mock-only — no real HA to import the helper from — and entity ids
are registry identity, so the mirror is deliberate.)
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
entity names through, which transliterates every script to ASCII
through unidecode (so non-Latin names survive: ``京子`` slugs to
``jing_zi``) before folding to lowercase and turning every run of
non-alphanumerics into one underscore, answering HA's ``unknown``
sentinel when a name slugs to nothing — then caps the result at
the object-id length the entity registry enforces (the registry
truncates the full ``sensor.`` + slug entity_id at 64 characters,
so the slug alone is capped like this; HA's slugify does not
truncate by itself).
"""
slug = unicodedata.normalize("NFKD", text)
slug = slug.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "_", slug.lower()).strip("_")
return (slug or "unknown")[:_MAX_SLUG_LENGTH]
return slugify(text)[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
+1
View File
@@ -21,6 +21,7 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"unidecode>=1.4.0",
"uvicorn>=0.30",
"voluptuous>=0.14",
]
+68
View File
@@ -24,16 +24,20 @@ from __future__ import annotations
import asyncio
import importlib.util
import inspect
import re
import sys
import tempfile
import unicodedata
import weakref
from dataclasses import dataclass
from html.entities import name2codepoint
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from unidecode import unidecode as _unidecode
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
@@ -46,6 +50,7 @@ _HA_MODULES = (
"homeassistant.config_entries",
"homeassistant.data_entry_flow",
"homeassistant.exceptions",
"homeassistant.util",
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.entity",
@@ -94,6 +99,7 @@ for _parent, _child in (
("homeassistant", "data_entry_flow"),
("homeassistant", "core"),
("homeassistant", "exceptions"),
("homeassistant", "util"),
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "entity"),
@@ -218,6 +224,68 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_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
class DeviceInfo:
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
+53 -2
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import datetime
import pytest
from homeassistant.util import slugify
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:
"""The slug mirror matches homeassistant.util.slugify for the names
children are registered with (verified against HA's own slugify)."""
"""The roster slugs match homeassistant.util.slugify — the real
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("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
# object id alone is capped at 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-asyncio" },
{ name = "pyyaml" },
{ name = "unidecode" },
{ name = "uvicorn" },
{ name = "voluptuous" },
]
@@ -400,6 +401,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "unidecode", specifier = ">=1.4.0" },
{ name = "uvicorn", specifier = ">=0.30" },
{ 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" },
]
[[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]]
name = "uvicorn"
version = "0.53.0"