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:
@@ -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]]:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user