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