From 2f055f406586047a53ece8d1a0132b1e075131e1 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 12:12:58 -0400 Subject: [PATCH] Roster slugs through Home Assistant's real slugify (P2 fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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. --- custom_components/nestquest/sensor.py | 39 +++++++-------- pyproject.toml | 1 + tests/conftest.py | 68 +++++++++++++++++++++++++++ tests/test_sensors.py | 55 +++++++++++++++++++++- uv.lock | 11 +++++ 5 files changed, 151 insertions(+), 23 deletions(-) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index 4159dc3..7bfdfc1 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -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 ``.`` 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]]: diff --git a/pyproject.toml b/pyproject.toml index b247a53..f1779bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/tests/conftest.py b/tests/conftest.py index efa617f..1ae82cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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. diff --git a/tests/test_sensors.py b/tests/test_sensors.py index ba7ffca..7a54df0 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -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__quests_due_today. + assert slugify(due_entity.name) == ( + f"nestquest_{roster_entry['slug']}_quests_due_today" + ) diff --git a/uv.lock b/uv.lock index 8f50a83..711e68e 100644 --- a/uv.lock +++ b/uv.lock @@ -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"