Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc97328b7b | ||
|
|
ee8ef6dcac |
@@ -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]]:
|
||||||
|
|||||||
@@ -51,6 +51,30 @@ class CyclePromptTests(unittest.TestCase):
|
|||||||
self.assertNotIn("The persistent user service runs", output)
|
self.assertNotIn("The persistent user service runs", output)
|
||||||
self.assertNotIn("---\n\n---\n\n# 27", output)
|
self.assertNotIn("---\n\n---\n\n# 27", output)
|
||||||
|
|
||||||
|
def test_task_complete_feature_triggers_one_guarded_promotion(self):
|
||||||
|
output = RENDER(TEMPLATE, PROFILE)
|
||||||
|
self.assertIn(
|
||||||
|
"feature becomes task-complete and its reviewed merge into `dev` is verified",
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
self.assertIn("Task completion inside an unmerged feature branch does not trigger", output)
|
||||||
|
self.assertIn("Do not create a duplicate promotion", output)
|
||||||
|
self.assertIn("Allow only one active promotion pull request to `main`", output)
|
||||||
|
self.assertIn("if it was closed without merge, recalculate", output)
|
||||||
|
self.assertIn("sole active orchestrator for the project", output)
|
||||||
|
self.assertIn("including controller exclusivity, reconciliation", output)
|
||||||
|
self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output)
|
||||||
|
self.assertIn("Active promotion branch and pull request", output)
|
||||||
|
self.assertIn("An active promotion that has not reached `RELEASE_READY`", output)
|
||||||
|
self.assertIn("RELEASE_PREPARATION_POLICY:\n", output)
|
||||||
|
self.assertIn("A ready promotion is a local checkpoint", output)
|
||||||
|
self.assertIn("continue that work while the ready promotion waits", output)
|
||||||
|
self.assertLess(
|
||||||
|
output.index("Begin an automatically triggered, requested, or scheduled release"),
|
||||||
|
output.index("Advance to the next eligible feature"),
|
||||||
|
)
|
||||||
|
self.assertIn("Only Joshua may merge into `main`", output)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ Possible scopes:
|
|||||||
- `PROJECT_QUEUE`
|
- `PROJECT_QUEUE`
|
||||||
- `RELEASE_PREPARATION`
|
- `RELEASE_PREPARATION`
|
||||||
|
|
||||||
|
Also set `RELEASE_PREPARATION_POLICY` at startup:
|
||||||
|
|
||||||
|
- `AUTO` by default.
|
||||||
|
- `EXCLUDED` only when the user's launch request explicitly says not to prepare
|
||||||
|
a release or `{{INTEGRATION_BRANCH}}`-to-`{{RELEASE_BRANCH}}` promotion.
|
||||||
|
|
||||||
|
Record both `MISSION_SCOPE` and `RELEASE_PREPARATION_POLICY` in durable run
|
||||||
|
state. Do not infer exclusion merely from `SINGLE_TASK` or `SINGLE_FEATURE`.
|
||||||
|
|
||||||
If the user explicitly names one task or feature, respect that scope.
|
If the user explicitly names one task or feature, respect that scope.
|
||||||
|
|
||||||
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
|
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
|
||||||
@@ -171,6 +180,7 @@ Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its
|
|||||||
Track at minimum:
|
Track at minimum:
|
||||||
|
|
||||||
- `MISSION_SCOPE`
|
- `MISSION_SCOPE`
|
||||||
|
- `RELEASE_PREPARATION_POLICY`
|
||||||
- Current feature
|
- Current feature
|
||||||
- Current controller phase
|
- Current controller phase
|
||||||
- Active development agents
|
- Active development agents
|
||||||
@@ -185,6 +195,9 @@ Track at minimum:
|
|||||||
- Blocked items
|
- Blocked items
|
||||||
- Tests/checks status
|
- Tests/checks status
|
||||||
- Last verified repository state
|
- Last verified repository state
|
||||||
|
- Active promotion branch and pull request
|
||||||
|
- Triggering feature and exact `{{INTEGRATION_BRANCH}}` commit for each automatic promotion
|
||||||
|
- Completed features pending the next promotion
|
||||||
- `NEXT_ACTION`
|
- `NEXT_ACTION`
|
||||||
|
|
||||||
After every material state transition:
|
After every material state transition:
|
||||||
@@ -254,8 +267,8 @@ Choose the highest-priority actionable transition in this order:
|
|||||||
8. Remediate feature-level review findings.
|
8. Remediate feature-level review findings.
|
||||||
9. Obtain feature approval.
|
9. Obtain feature approval.
|
||||||
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
|
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
|
||||||
11. Advance to the next eligible feature when mission scope permits.
|
11. Begin an automatically triggered, requested, or scheduled release when appropriate. The automatic post-feature trigger is in scope for both `SINGLE_FEATURE` and `PROJECT_QUEUE` unless `RELEASE_PREPARATION_POLICY = EXCLUDED`.
|
||||||
12. Begin a requested/scheduled release when appropriate.
|
12. Advance to the next eligible feature when mission scope permits.
|
||||||
13. Continue unrelated work around locally blocked items.
|
13. Continue unrelated work around locally blocked items.
|
||||||
14. Escalate only when the defined escalation rules require it.
|
14. Escalate only when the defined escalation rules require it.
|
||||||
|
|
||||||
@@ -588,9 +601,10 @@ Immediately transition to `FEATURE_INTEGRATION`.
|
|||||||
|
|
||||||
A merged feature is a transition.
|
A merged feature is a transition.
|
||||||
|
|
||||||
It is terminal ONLY when:
|
It is terminal only when `MISSION_SCOPE = SINGLE_FEATURE` and no authorized
|
||||||
|
post-feature release transition is pending. Preparing the automatic promotion
|
||||||
`MISSION_SCOPE = SINGLE_FEATURE`
|
for that completed feature is part of `SINGLE_FEATURE` scope when
|
||||||
|
`RELEASE_PREPARATION_POLICY = AUTO`.
|
||||||
|
|
||||||
Otherwise select the next eligible work.
|
Otherwise select the next eligible work.
|
||||||
|
|
||||||
@@ -637,13 +651,19 @@ The orchestrator itself does not perform the merge.
|
|||||||
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
|
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
|
||||||
|
|
||||||
1. Reconcile {{WORK_TRACKER}}.
|
1. Reconcile {{WORK_TRACKER}}.
|
||||||
2. Check for a requested/scheduled release.
|
2. Evaluate the automatic, requested, and scheduled release triggers.
|
||||||
3. Identify remaining incomplete features.
|
3. Identify remaining incomplete features.
|
||||||
4. Determine which features are eligible based on dependencies and priority.
|
4. Determine which features are eligible based on dependencies and priority.
|
||||||
5. Select the next eligible feature.
|
5. Select the next eligible feature.
|
||||||
6. Initialize it.
|
6. Initialize it.
|
||||||
7. Continue the development loop.
|
7. Continue the development loop.
|
||||||
|
|
||||||
|
If an automatic promotion becomes `RELEASE_READY` while another eligible
|
||||||
|
feature remains, keep that promotion waiting for {{RELEASE_OWNER_NAME}} and
|
||||||
|
continue the project queue. A ready promotion is a local checkpoint in this
|
||||||
|
case, not a global terminal condition. Features completed while it remains open
|
||||||
|
are recorded as pending for the next promotion under Section 18.
|
||||||
|
|
||||||
Do not return to the user simply because one feature completed.
|
Do not return to the user simply because one feature completed.
|
||||||
|
|
||||||
If several features are available, choose using:
|
If several features are available, choose using:
|
||||||
@@ -658,27 +678,52 @@ If several features are available, choose using:
|
|||||||
|
|
||||||
# 18. RELEASE RULES
|
# 18. RELEASE RULES
|
||||||
|
|
||||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
|
Completed features accumulate in `{{INTEGRATION_BRANCH}}`. With no active
|
||||||
|
promotion, each verified feature completion starts release preparation. While
|
||||||
|
one promotion is active, later completed features accumulate for the next
|
||||||
|
promotion.
|
||||||
|
|
||||||
Prepare a release only when:
|
Prepare a release when any of these conditions is true:
|
||||||
|
|
||||||
- {{RELEASE_OWNER_NAME}} requests it; OR
|
- {{RELEASE_OWNER_NAME}} requests it; OR
|
||||||
- {{WORK_TRACKER}} schedules it.
|
- {{WORK_TRACKER}} schedules it; OR
|
||||||
|
- `RELEASE_PREPARATION_POLICY = AUTO`, and a feature becomes task-complete and its reviewed merge into `{{INTEGRATION_BRANCH}}` is verified.
|
||||||
|
|
||||||
|
For the automatic trigger, a feature is task-complete only when:
|
||||||
|
|
||||||
|
- Every required feature task is `done`, or deliberately `canceled` with recorded evidence.
|
||||||
|
- No task review, remediation, approval, or merge remains pending.
|
||||||
|
- The feature acceptance criteria and integrated checks pass.
|
||||||
|
- Independent approval applies to the exact feature head.
|
||||||
|
- The approver's merge of that head into `{{INTEGRATION_BRANCH}}` is verified.
|
||||||
|
- {{WORK_TRACKER}} reflects the completed feature and exact repository references.
|
||||||
|
|
||||||
|
Task completion inside an unmerged feature branch does not trigger a release.
|
||||||
|
The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transition.
|
||||||
|
|
||||||
|
Before creating a release branch or pull request:
|
||||||
|
|
||||||
|
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` must enforce that exclusivity. If sole ownership cannot be verified, do not create a release branch or pull request; escalate instead.
|
||||||
|
2. Reconcile open release records, release branches, and promotion pull requests.
|
||||||
|
3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit.
|
||||||
|
4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head.
|
||||||
|
5. Record the triggering feature and exact `{{INTEGRATION_BRANCH}}` commit so the automatic trigger runs once for that merge.
|
||||||
|
|
||||||
Release process:
|
Release process:
|
||||||
|
|
||||||
1. Reconcile release scope.
|
1. Enforce every pre-creation requirement above, including controller exclusivity, reconciliation, duplicate and active-promotion checks, and trigger recording.
|
||||||
2. Confirm included features are complete.
|
2. Reconcile release scope.
|
||||||
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
3. Confirm included features are complete.
|
||||||
4. Run complete release checks.
|
4. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||||
5. Open release PR → `{{RELEASE_BRANCH}}`.
|
5. Run complete release checks.
|
||||||
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
6. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||||
7. Remediate findings through development agents.
|
7. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||||
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
8. Remediate findings through development agents.
|
||||||
9. Revalidate.
|
9. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||||
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
10. Revalidate.
|
||||||
11. Update {{WORK_TRACKER}}.
|
11. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||||
12. Stop before {{RELEASE_BRANCH}} merge.
|
12. Update {{WORK_TRACKER}}.
|
||||||
|
13. Stop before {{RELEASE_BRANCH}} merge.
|
||||||
|
|
||||||
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
|
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
|
||||||
|
|
||||||
@@ -724,13 +769,23 @@ For `PROJECT_QUEUE`, this additionally requires:
|
|||||||
|
|
||||||
- No eligible incomplete feature remains.
|
- No eligible incomplete feature remains.
|
||||||
- No task/review/remediation/merge is pending.
|
- No task/review/remediation/merge is pending.
|
||||||
|
- No release preparation, release review, or release remediation is pending.
|
||||||
|
- Every in-scope automatic trigger is either incorporated into the active
|
||||||
|
`RELEASE_READY` promotion or durably recorded for the next promotion.
|
||||||
- No development agent is active.
|
- No development agent is active.
|
||||||
- No approver is active.
|
- No approver is active.
|
||||||
|
|
||||||
|
An active promotion that has not reached `RELEASE_READY` makes
|
||||||
|
`MISSION_COMPLETE` false.
|
||||||
|
|
||||||
## `RELEASE_READY`
|
## `RELEASE_READY`
|
||||||
|
|
||||||
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
|
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
|
||||||
|
|
||||||
|
`RELEASE_READY` is globally terminal only when no other in-scope engineering
|
||||||
|
action is executable. In `PROJECT_QUEUE`, if another eligible incomplete
|
||||||
|
feature remains, continue that work while the ready promotion waits.
|
||||||
|
|
||||||
## `GLOBAL_ESCALATION`
|
## `GLOBAL_ESCALATION`
|
||||||
|
|
||||||
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
|
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
|
||||||
@@ -778,9 +833,12 @@ Output a machine-readable continuation checkpoint:
|
|||||||
```text
|
```text
|
||||||
ORCHESTRATOR_STATE: CONTINUE
|
ORCHESTRATOR_STATE: CONTINUE
|
||||||
MISSION_SCOPE:
|
MISSION_SCOPE:
|
||||||
|
RELEASE_PREPARATION_POLICY:
|
||||||
CONTROLLER_PHASE:
|
CONTROLLER_PHASE:
|
||||||
CURRENT_FEATURE:
|
CURRENT_FEATURE:
|
||||||
ACTIVE_TASKS:
|
ACTIVE_TASKS:
|
||||||
|
ACTIVE_PROMOTION:
|
||||||
|
PENDING_PROMOTION_FEATURES:
|
||||||
BLOCKED_ITEMS:
|
BLOCKED_ITEMS:
|
||||||
LAST_VERIFIED_STATE:
|
LAST_VERIFIED_STATE:
|
||||||
NEXT_ACTION:
|
NEXT_ACTION:
|
||||||
|
|||||||
@@ -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