sandbox: drop development-phase references from code

The final deliverable should not carry the scaffolding of the phases it was
built in. Reword comments, docstrings, and generated-output strings that named
build phases (Phase N / T1-T3 / Phase A1-A2) to describe what the code does,
and rename the phase-numbered test files:

  test_phase4_subprocess  -> test_subprocess
  test_phase9_shutdown    -> test_shutdown
  test_phase13_proxies    -> test_domain_proxies
  test_phase14            -> test_schema_and_unload
  test_phase19_devices    -> test_device_registry

Comments/docstrings/filenames only; no logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:12:23 -04:00
co-authored by Claude Opus 4.8
parent f5518f3705
commit ee74f766bb
51 changed files with 204 additions and 206 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ The integration owns three runtime objects, all hung off
* :class:`SandboxManager` — supervises one subprocess per sandbox group
("main", "built-in", "custom"), lazily spawning them on first need.
* :class:`SandboxFlowRouter` — installed as
``hass.config_entries.router`` (Phase 4). Diverts new config flows to
``hass.config_entries.router``. Diverts new config flows to
sandbox runtimes and routes ``async_setup_entry`` for tagged entries.
* :class:`SandboxBridge` (one per running sandbox) — owns the entity-side
protocol: receives ``register_entity`` + ``state_changed`` pushes from
@@ -57,7 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
data.bridges[group] = async_create_bridge(hass, group=group, channel=channel)
async def _on_shutdown_reply(group: str, reply: Any) -> None:
"""Persist the sandbox's restore-state snapshot (Phase 9).
"""Persist the sandbox's restore-state snapshot.
The runtime ships its ``RestoreEntity`` state in the shutdown
reply (a ``ShutdownResult``) rather than via the sandbox store
@@ -100,7 +100,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _on_stop(_event: Event) -> None:
"""Stop every sandbox process on HA shutdown.
Phase 9: ask each sandbox to unload its entries and flush
Ask each sandbox to unload its entries and flush
``RestoreEntity`` state through the ``current_sandbox`` store
bridge before pulling the plug. ``async_stop_all`` then handles SIGTERM
/ SIGKILL for any sandbox that didn't ack the graceful request
@@ -2,8 +2,8 @@
`classify(integration)` is a pure function from a loaded `Integration`
(manifest + on-disk shape) to a `SandboxAssignment`. It is called by the
config-flow router (Phase 4) and by config-entry setup interception
(Phase 4) — every decision about "main vs sandbox" funnels through here.
config-flow router and by config-entry setup interception — every
decision about "main vs sandbox" funnels through here.
Rule order (first match wins):
+2 -2
View File
@@ -54,8 +54,8 @@ ALWAYS_MAIN: frozenset[str] = frozenset(
# objects with Path values + temp files before the entity method
# runs. Neither bridge option intercepts at service-call level yet,
# and resolution depends on camera/image bytes (deny-listed). Folded
# in the Phase 1 decision doc — revisit when ai_task is made
# sandbox-aware or we add service-handler-level interception.
# into ALWAYS_MAIN — revisit when ai_task is made sandbox-aware or
# we add service-handler-level interception.
"ai_task",
# image owns the same bytes-returning entity surface camera does;
# the deny-list above catches integrations *providing* an image
@@ -7,9 +7,8 @@ service-handler kwarg filtering (``light.filter_turn_on_params``,
``climate`` schema validation, …) and frontend rendering see the same
shape they would for a local entity.
Phase 5 ships proxies for the small "rich" set the spike and tests
exercise. The remaining domains from the v1 list use the same mechanical
pattern — see ``plan.md`` Phase 5's deferral note.
A small "rich" set of domains ships typed proxies; the remaining
domains use the same mechanical pattern.
"""
import contextlib
@@ -13,7 +13,7 @@ class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
Calendar service calls go through the standard ``calendar.*`` service
handlers; the listing/iteration APIs are server-side queries we don't
proxy in Phase 13 (no test infra exercises them yet).
proxy (no test infra exercises them yet).
"""
@property
@@ -1,8 +1,8 @@
"""Sandbox proxy for ``scene`` entities.
``scene`` is in ``ALWAYS_MAIN`` so the classifier never routes it to a
sandbox in practice. The proxy ships anyway for symmetry — Phase 13
covers the full set so a future classifier change doesn't surprise us.
sandbox in practice. The proxy ships anyway for symmetry — the full
set is covered so a future classifier change doesn't surprise us.
"""
from typing import Any
@@ -32,10 +32,10 @@ class SandboxTodoListEntity(SandboxProxyEntity, TodoListEntity):
@property
def todo_items(self) -> list[TodoItem] | None:
"""Item iteration happens on the sandbox side; do not proxy items."""
# The Phase-13 proxy only mirrors state + service calls. Listing
# items is a server-side query that needs the same bridge plumbing
# ``calendar`` does and is deferred until those operations get a
# cross-process protocol (out of scope for this phase).
# The proxy only mirrors state + service calls. Listing items is a
# server-side query that needs the same bridge plumbing ``calendar``
# does and is deferred until those operations get a cross-process
# protocol.
return None
async def async_create_todo_item(self, item: TodoItem) -> None:
@@ -25,7 +25,7 @@ class SandboxWeatherEntity(SandboxProxyEntity, WeatherEntity):
Forecasts are computed by the sandbox-side ``WeatherEntity`` and
pushed through the ``weather.get_forecasts`` service path, not over
the entity-method bridge — Phase 13 only proxies the condition +
the entity-method bridge — the proxy only mirrors the condition +
instantaneous attributes.
"""
+6 -6
View File
@@ -1,7 +1,7 @@
"""Sandbox — subprocess lifecycle and supervision.
Phase 3 building block. The manager owns one supervised subprocess per
sandbox group (``main`` / ``built-in`` / ``custom``); higher phases call
The manager owns one supervised subprocess per sandbox group
(``main`` / ``built-in`` / ``custom``); callers invoke
:meth:`SandboxManager.ensure_started` lazily as config entries are routed.
The contract between manager and runtime is:
@@ -118,7 +118,7 @@ class SandboxProcess:
manager's loop.
``on_shutdown_reply`` is invoked with the runtime's reply to
:data:`MSG_SHUTDOWN` (Phase 9) so the caller can persist any
:data:`MSG_SHUTDOWN` so the caller can persist any
``restore_state`` payload before the subprocess exits.
"""
self.group = group
@@ -225,7 +225,7 @@ class SandboxProcess:
self._state = "stopped"
async def async_graceful_shutdown(self, *, timeout: float) -> bool:
"""Phase 9: ask the runtime to unload + flush, then wait for exit.
"""Ask the runtime to unload + flush, then wait for exit.
Sends ``sandbox/shutdown`` over the live channel and waits up
to ``timeout`` for the runtime to reply and then exit on its
@@ -551,7 +551,7 @@ class SandboxManager:
back). Unix is opt-in so existing deployments keep using stdio.
``on_channel_ready`` is invoked once a sandbox's control channel is
live; Phase 4's router uses it to register inbound flow handlers
live; the router uses it to register inbound flow handlers
(e.g., ``sandbox/notify_flow_changed``).
"""
self._hass = hass
@@ -637,7 +637,7 @@ class SandboxManager:
)
async def async_graceful_shutdown_all(self, *, timeout: float) -> None:
"""Phase 9: ask every running sandbox to shut down gracefully.
"""Ask every running sandbox to shut down gracefully.
Best-effort fan-out. Sandboxes that did not ack inside ``timeout``
are left for :meth:`async_stop_all` to clean up with SIGTERM /
+9 -9
View File
@@ -29,7 +29,7 @@ Main → Sandbox calls:
fetches the code before setup (see ``hass_client.sources``).
* ``sandbox/entry_unload`` — ask the sandbox to unload an entry by id.
* ``sandbox/call_service`` — generic service dispatch (shared with
Phase 6's main→sandbox service mirroring path). Payload mirrors a
the main→sandbox service mirroring path). Payload mirrors a
``ServiceCall``: ``(domain, service, target, service_data, context,
return_response)``. Returns either ``None`` or a service-response dict.
@@ -39,7 +39,7 @@ Sandbox → Main calls:
entity, here's its description". Main builds the proxy and replies
``{"entity_id": <main-side id>}`` so the sandbox can route later
``call_service`` requests back to the right local entity. Optional
``device_info`` field (Phase 19): a JSON-flattened ``DeviceInfo`` dict
``device_info`` field: a JSON-flattened ``DeviceInfo`` dict
— sets become lists of two-element lists (``identifiers`` /
``connections``), tuples become lists (``via_device``), and
``entry_type`` is the enum's string value. When present, main calls
@@ -48,28 +48,28 @@ Sandbox → Main calls:
* ``sandbox/unregister_entity`` — symmetric counterpart.
* ``sandbox/state_changed`` — push (no response). Carries the
marshalled state delta for one entity.
* ``sandbox/register_service`` (Phase 6) — sandbox tells main "I just
* ``sandbox/register_service`` — sandbox tells main "I just
registered a service, please mirror it". Main installs a thin handler
that forwards calls back over the shared ``sandbox/call_service``
channel.
* ``sandbox/unregister_service`` (Phase 6) — symmetric counterpart.
* ``sandbox/fire_event`` (Phase 6) — push (no response). The sandbox
* ``sandbox/unregister_service`` — symmetric counterpart.
* ``sandbox/fire_event`` — push (no response). The sandbox
forwards each ``<owned_domain>_*`` event so main listeners (notably
``automation``) can react as if the integration ran locally.
* ``sandbox/store_load`` (Phase 8) — sandbox-side ``Store.async_load``
* ``sandbox/store_load`` — sandbox-side ``Store.async_load``
proxies to this RPC. Payload ``{"key": str}``; response is the wrapped
``{"version", "minor_version", "key", "data"}`` dict the sandbox last
saved, or ``None`` if no data exists yet. The group is implicit from
the channel — each :class:`SandboxBridge` only ever serves one group.
* ``sandbox/store_save`` (Phase 8) — sandbox-side ``Store`` flush.
* ``sandbox/store_save`` — sandbox-side ``Store`` flush.
Payload ``{"key": str, "data": dict}``; main writes the wrapped dict
to ``<config>/.storage/sandbox/<group>/<key>`` atomically. Response
is ``{"ok": True}``.
* ``sandbox/store_remove`` (Phase 8) — sandbox-side
* ``sandbox/store_remove`` — sandbox-side
``Store.async_remove``. Payload ``{"key": str}``; main unlinks the
file (if any). Response is ``{"ok": True}``.
Main → Sandbox shutdown (Phase 9):
Main → Sandbox shutdown:
* ``sandbox/shutdown`` — ask the runtime to unload its entries, dump
``RestoreEntity`` state, fire ``EVENT_HOMEASSISTANT_FINAL_WRITE`` so any
@@ -234,12 +234,12 @@ class SandboxFlowProxy(ConfigFlow):
)
# Any other type (MENU, EXTERNAL_STEP, SHOW_PROGRESS, …) is
# explicitly out of Phase 4 scope; surface a noisy abort so a
# follow-up doesn't silently drop the flow on the floor.
# not supported; surface a noisy abort so a follow-up doesn't
# silently drop the flow on the floor.
self._terminated = True
_LOGGER.warning(
"Sandbox %r returned unsupported flow result type %s for %s;"
" aborting (Phase 4 supports FORM/CREATE_ENTRY/ABORT only)",
" aborting (only FORM/CREATE_ENTRY/ABORT are supported)",
self._sandbox_group,
result_type,
self._handler_key,
+5 -5
View File
@@ -9,7 +9,7 @@ signature matchers, and writes:
(``{bucket → {integration → [test_node, …]}}``) for downstream tooling.
- A short stdout summary suitable for `tee`-ing onto an issue.
The bucket list mirrors the Phase 16 spec in ``plan.md``. Categories are
Categories are
ordered most-specific → most-generic; the first match wins, so unknown
genuinely means "no rule fired". A "≥95% of failures buckets out of
``unknown``" smoke gate lives at the bottom of the run; if that fails,
@@ -65,7 +65,7 @@ def _compile(pattern: str) -> re.Pattern[str]:
RULES: tuple[Rule, ...] = (
# ---- test-only -------------------------------------------------------
# Phase 15's autotag patch mutates entry.data; tests that assert
# The autotag patch mutates entry.data; tests that assert
# ``entry.data == <anything>`` or snapshot it see the new
# ``__sandbox_group`` key. Same root cause whether it surfaces in an
# `assert`, a `mappingproxy(...)` repr (pytest truncates the diff into
@@ -88,7 +88,7 @@ RULES: tuple[Rule, ...] = (
"test-only",
_compile(r"assert\s+mappingproxy\(.+?\)\s*==\s*[\{\[]"),
),
# Post-Phase-17: diagnostic snapshots that include the entry's
# Diagnostic snapshots that include the entry's
# full ``as_dict()`` now see a top-level ``+ 'sandbox': '<group>'``
# line in the diff. Same root cause as ``__sandbox_group`` — the
# autotag synthesises the field for compat coverage, the snapshot
@@ -130,7 +130,7 @@ RULES: tuple[Rule, ...] = (
),
# ---- protocol gaps ---------------------------------------------------
# data_schema serialisation drift. Phase 14 added voluptuous-serialize
# data_schema serialisation drift. The bridge does voluptuous-serialize
# round-tripping; a regression would surface as None / wrong type.
Rule(
"data-schema-stripped",
@@ -310,7 +310,7 @@ def render_summary(
for node_list in integrations.values()
)
lines = [
"Phase 16 failure categorisation",
"Failure categorisation",
"-" * 32,
f"Total failures bucketed: {total_failures}",
"",
+15 -14
View File
@@ -2,7 +2,7 @@
Pairs with ``categorize_failures.py``. Reads ``BACKLOG_FAILURES.json``
(``{bucket → {integration → [{node_id, excerpt}]}}``) and writes the
section-per-bucket Markdown the Phase 16 spec asks for.
section-per-bucket Markdown backlog.
The "Proposed fix" stub is intentionally left as a TODO marker per
section — that field needs human judgement (file paths, rough size,
@@ -38,55 +38,56 @@ BUCKET_BLURB: dict[str, str] = {
" a snapshot that pre-dates the tag. Not a sandbox bridge bug."
),
"proxy-missing": (
"Phase 13 shipped proxies for all 32 entity domains. Any hit here"
"The bridge ships proxies for all 32 entity domains. Any hit here"
" means the integration registers an entity in a domain the bridge"
" doesn't recognise — either a new domain landed in HA Core or the"
" dispatch map missed one."
),
"data-schema-stripped": (
"Phase 14 added a `voluptuous_serialize`-based round-trip so flow"
"The bridge does a `voluptuous_serialize`-based round-trip so flow"
" schemas survive the bridge. Any hit here means a `vol.Schema`"
" shape the serializer can't represent (custom validators, untagged"
" `vol.Any`, etc.)."
),
"service-schema-missing": (
"Mirror of `data-schema-stripped` for `hass.services.async_register`."
" Phase 14 ships the same bridge for service schemas; gaps here are"
" The same bridge serialises service schemas; gaps here are"
" edge cases (custom validators, schema-on-the-fly registrations)."
),
"unique-id-not-propagated": (
"Phase 14 marshals `flow.context['unique_id']` into the proxy. Hits"
"The bridge marshals `flow.context['unique_id']` into the proxy. Hits"
" here are flow shapes that set unique_id outside the standard"
" `async_set_unique_id` path (e.g. discovery flows that abort"
" inside `async_step_user`)."
),
"restore-state-not-applied": (
"Phase 9 warm-loads `RestoreStateData` from"
"The runtime warm-loads `RestoreStateData` from"
" `<config>/.storage/sandbox/<group>/core.restore_state`. Hits"
" here mean an integration's restore-state assertion fires before"
" the warm-load completes, or expects state the previous run never"
" persisted."
),
"context-not-propagated": (
"Phase 6 forwards events with the sandbox-side `context_id` but does"
" NOT honour it for main's user/origin resolution. Integration tests"
"The event mirror forwards events with the sandbox-side `context_id`"
" but does NOT honour it for main's user/origin resolution. Integration"
" tests"
" that assert on `context.user_id` or `context.parent_id` fail here."
" Carrying a richer Context shape is post-v2 work."
),
"re-entrant-channel": (
"Phase 12 made the channel dispatcher concurrent so a handler can"
"The channel dispatcher is concurrent so a handler can"
" issue `channel.call(...)`. Hits here mean a re-entrant shape we"
" haven't seen — the semaphore cap might be too aggressive, or a"
" handler chain exceeds the in-flight ceiling."
),
"store-key-rejected": (
"Phase 8's `RemoteStore` validator rejects keys containing `/`, `\\`,"
"The `RemoteStore` validator rejects keys containing `/`, `\\`,"
" NUL, `.`, or `..`. An integration using one of those characters"
" trips this. Probably wants a translation step on the sandbox side"
" rather than relaxing the validator."
),
"flow-step-not-async": (
"Phase 4's flow proxy expects every step on the integration's"
"The flow proxy expects every step on the integration's"
" `ConfigFlow` to be an async coroutine. A sync step would emit a"
" `coroutine 'async_step_*' was never awaited` warning that this"
" bucket catches."
@@ -97,7 +98,7 @@ BUCKET_BLURB: dict[str, str] = {
" has a bug or the platform-set should grow."
),
"non-idempotent-service-handler": (
"The `ai_task`/`image` shape Phase 1 surfaced — the service handler"
"The `ai_task`/`image` shape — the service handler"
" does material work before calling the entity method. Today's"
" resolution is `ALWAYS_MAIN`; integrations that hit this should"
" join that set or sandbox-handler interception needs designing"
@@ -144,12 +145,12 @@ def _bucket_priority(name: str) -> int:
def render_backlog(payload: dict[str, dict[str, list[dict[str, str]]]]) -> str:
"""Render the BACKLOG.md draft."""
lines: list[str] = [
"# Sandbox — Phase 16 backlog",
"# Sandbox — compat sweep backlog",
"",
"**Auto-generated draft** from `BACKLOG_FAILURES.json`."
" `generate_backlog.py` writes the skeleton; the *Proposed fix* and"
" *Estimated size* lines need human curation before this lands as the"
" final Phase 16 deliverable.",
" final deliverable.",
"",
"Sections are ordered by integration count (largest first). Within"
" each section the affected-integration roll-up is capped at 10 — the"
@@ -1,4 +1,4 @@
"""Sandbox-side approved-domains gate (Phase 6).
"""Sandbox-side approved-domains gate.
A single shared :class:`ApprovedDomains` instance tracks which domains
the sandbox is allowed to own. It is the firewall the user asked for:
@@ -1,4 +1,4 @@
"""Sandbox-side event mirror (Phase 6).
"""Sandbox-side event mirror.
Forwards every event whose ``event_type`` matches ``<approved_domain>_*``
up to main via ``sandbox/fire_event``. Canonical examples: ``zha_event``,
@@ -12,9 +12,8 @@ manager-side proxy :class:`ConfigFlow` calls these handlers across the
Flow results cross the wire as plain dicts. ``data_schema`` and the
``progress_task`` field are intentionally stripped — the schema lives on
the sandbox where validation happens, and the task is a runtime object
that can't be serialised. Phase 5 lifts the bridge to a richer
representation; the docstring in ``_marshal_result`` is the load-bearing
note for that follow-up.
that can't be serialised. The docstring in ``_marshal_result`` is the
load-bearing note for how the schema is later marshalled.
"""
from collections.abc import Mapping
@@ -69,7 +68,7 @@ class _SandboxFlowManager(ConfigEntriesFlowManager):
Main owns the canonical entry store; the sandbox just runs the flow
and returns the result. The default ``async_finish_flow`` would
create an entry inside the sandbox-private store and try to set the
integration up locally — that's Phase 5 / 6 work, not Phase 4's.
integration up locally — that's later work, not this layer's.
"""
async def async_finish_flow(
@@ -149,7 +148,7 @@ def _marshal_result(
) -> pb.FlowResult:
"""Marshal a FlowResult into the typed ``FlowResult`` message.
``data_schema`` is rendered via :func:`serialize_schema` (Phase 14)
``data_schema`` is rendered via :func:`serialize_schema` —
the wire payload carries the same list-of-fields shape
:func:`voluptuous_serialize.convert` produces, so the proxy on main
can rebuild a usable :class:`vol.Schema`. ``flow.context`` (which
@@ -3,21 +3,21 @@
Composes the sandbox's per-process services:
* :class:`FlowRunner` — drives integration ``ConfigFlow`` instances
out-of-process (Phase 4).
out-of-process.
* :class:`EntryRunner` — accepts ``sandbox/entry_setup`` pushes and
runs ``async_setup_entry`` against the sandbox-private HA (Phase 5).
runs ``async_setup_entry`` against the sandbox-private HA.
* :class:`EntityBridge` — pushes entity registrations + state changes
back to main (Phase 5).
back to main.
* :class:`ServiceMirror` / :class:`EventMirror` — mirror service
registrations and ``<owned_domain>_*`` events up to main, gated by
:class:`ApprovedDomains` (Phase 6).
:class:`ApprovedDomains`.
The handshake: open the control channel (transport selected by the
``--url`` scheme — ``stdio://`` by default, ``unix://<path>`` to dial back
to the manager's unix socket), send a :data:`MSG_READY` frame as the first
message, warm-load restore state, register handlers, then idle until
SIGTERM (or until main asks for a graceful shutdown over the channel — see
Phase 9's :meth:`SandboxRuntime._handle_shutdown`).
:meth:`SandboxRuntime._handle_shutdown`).
"""
import asyncio
@@ -173,7 +173,7 @@ class SandboxRuntime:
"one event loop? (see plan Risk #3)"
)
sandbox_token = current_sandbox.set(ChannelSandboxBridge(self._channel))
# Phase 9: start the channel reader first so the warm-load
# Start the channel reader first so the warm-load
# round-trip can resolve, then pre-load this sandbox group's
# restore-state cache. The contextvar (set above) routes the
# load to main. The data lives on main under
@@ -241,7 +241,7 @@ class SandboxRuntime:
return await _open_stdio_channel(name=self.group)
async def _handle_shutdown(self, _payload: object) -> pb.ShutdownResult:
"""Phase 9: unload entries, flush restore state, then exit cleanly.
"""Unload entries, flush restore state, then exit cleanly.
Runs inside the channel dispatcher so the reply is written before
the runtime starts its teardown. The actual shutdown event is set
@@ -257,7 +257,7 @@ class SandboxRuntime:
async def _run_graceful_shutdown(self) -> pb.ShutdownResult:
"""Unload every loaded entry and snapshot RestoreEntity state.
Phase 12 fires ``EVENT_HOMEASSISTANT_FINAL_WRITE`` and waits for
Fires ``EVENT_HOMEASSISTANT_FINAL_WRITE`` and waits for
the bus to drain so ``Store``s with pending ``async_delay_save``
writes flush to main via the ``current_sandbox`` bridge — the
now-concurrent channel dispatcher means the re-entrant
@@ -269,7 +269,7 @@ class SandboxRuntime:
is owned by the runtime's explicit warm-load / shutdown-dump path,
not by an integration's ``Store``, so it doesn't ride the
FINAL_WRITE flush. Shipping it back in the reply keeps the data
path symmetric with Phase 9 — main writes it via
path symmetric with the warm-load — main writes it via
:meth:`SandboxBridge._handle_store_save`-style atomic write.
"""
flow_runner = self._flow_runner
@@ -291,7 +291,7 @@ class SandboxRuntime:
if ok:
unloaded += 1
# Phase 12: fire FINAL_WRITE so ``async_delay_save``-using
# Fire FINAL_WRITE so ``async_delay_save``-using
# ``Store``s flush their pending data. Concurrent channel
# dispatcher means each bridge write can re-enter the channel
# without deadlocking against this handler.
@@ -333,7 +333,7 @@ async def _load_restore_state(hass: Any) -> None:
periodic ``async_setup_dump`` listener via ``start.async_at_start``,
which only fires on a fully-started HA. The sandbox's HA never goes
through ``async_start``, so we skip that listener and rely on
Phase 9's shutdown handler to force the final dump.
the shutdown handler to force the final dump.
No store swap is needed: ``RestoreStateData`` builds a vanilla
``Store``, and ``Store.async_load`` reads ``current_sandbox`` at call
@@ -1,8 +1,8 @@
"""Entry point for ``python -m hass_client.sandbox``.
The Sandbox manager spawns this module as a subprocess. CLI arguments
mirror what the websocket client will need in Phase 4 so the manager-side
command line is stable across phases.
mirror what the websocket client needs so the manager-side command line
is stable.
"""
import argparse
@@ -6,7 +6,7 @@ control channel: the three ``Store`` IO methods delegate to main via the
namespaces every key as ``<config>/.storage/sandbox/<group>/<key>`` so
two sandbox processes — or main itself — can't read each other's data.
The bodies are lifted from the pre-contextvar Phase 8 store subclass that
The bodies are lifted from the pre-contextvar store subclass that
this primitive replaced: same load semantics, same orjson preserialise on
save, same channel error handling. The difference is *how* it's wired —
``Store`` reads ``current_sandbox`` at call time instead of being rebound
@@ -1,4 +1,4 @@
"""Sandbox-side service-registration mirror (Phase 6).
"""Sandbox-side service-registration mirror.
Watches ``EVENT_SERVICE_REGISTERED`` / ``EVENT_SERVICE_REMOVED`` on the
sandbox bus. For each registration whose domain is in
@@ -6,7 +6,7 @@ sandbox bus. For each registration whose domain is in
main with the metadata main needs to install a forwarding handler. Same
shape for removals via ``sandbox/unregister_service``.
Schemas are intentionally not serialised in Phase 6 the sandbox is the
Schemas are intentionally not serialised the sandbox is the
authoritative validator (the call comes back over
``sandbox/call_service`` and is run through ``services.async_call``
on the sandbox side, where the real schema lives). Main only needs
@@ -1,4 +1,4 @@
"""Phase 1 entity-bridge spike.
"""Entity-bridge spike.
Two protocols are implemented in parallel:
@@ -11,8 +11,7 @@ classifies the entry's domain and, if it routes to a sandbox group,
sets :attr:`ConfigEntry.sandbox` to the matching group name before
the original ``add_to_hass`` adds it to the manager. Setting the
field rather than mutating ``entry.data`` keeps the autotag invisible
to integration tests that assert on data contents (the Phase 17 fix
the BACKLOG had as the single highest-leverage gap).
to integration tests that assert on data contents.
The classifier here is a synchronous filesystem-only re-implementation
of :func:`homeassistant.components.sandbox.classifier.classify`.
@@ -1,4 +1,4 @@
"""Tests for the Phase 6 :class:`ApprovedDomains` gate."""
"""Tests for the :class:`ApprovedDomains` gate."""
from hass_client.approved_domains import ApprovedDomains
@@ -1,4 +1,4 @@
"""Phase 5 tests for :class:`hass_client.entity_bridge.EntityBridge`.
"""Tests for :class:`hass_client.entity_bridge.EntityBridge`.
The bridge listens for ``EVENT_STATE_CHANGED`` on the sandbox-private
:class:`HomeAssistant`. We drive it by registering a fake entity into a
+15 -15
View File
@@ -1,4 +1,4 @@
"""Phase 5 tests for :class:`hass_client.entry_runner.EntryRunner`.
"""Tests for :class:`hass_client.entry_runner.EntryRunner`.
Exercises the sandbox-side ``sandbox/entry_setup`` round-trip plus the
``sandbox/call_service`` channel.
@@ -86,35 +86,35 @@ async def test_entry_setup_calls_integration_setup_entry(
async def _async_unload_entry(_hass: Any, _entry: ConfigEntry) -> bool:
return True
class _DemoFlow(ConfigFlow, domain="phase5_demo"):
class _DemoFlow(ConfigFlow, domain="demo_setup"):
VERSION = 1
# `ConfigFlow.__init_subclass__` adds _DemoFlow to HANDLERS — clean it
# back up at teardown so other tests don't see a stale handler.
assert "phase5_demo" in ha_config_entries.HANDLERS
assert "demo_setup" in ha_config_entries.HANDLERS
# Stand up a fake integration in the loader caches. Both the main
# module and the config_flow module must be present in DATA_COMPONENTS
# — entry.async_setup imports the latter before calling
# async_setup_entry.
module = ModuleType("homeassistant.components.phase5_demo")
module.DOMAIN = "phase5_demo"
module = ModuleType("homeassistant.components.demo_setup")
module.DOMAIN = "demo_setup"
module.async_setup_entry = _async_setup_entry # type: ignore[attr-defined]
module.async_unload_entry = _async_unload_entry # type: ignore[attr-defined]
config_flow_module = ModuleType("homeassistant.components.phase5_demo.config_flow")
runner.hass.data[ha_loader.DATA_COMPONENTS]["phase5_demo"] = module
runner.hass.data[ha_loader.DATA_COMPONENTS]["phase5_demo.config_flow"] = (
config_flow_module = ModuleType("homeassistant.components.demo_setup.config_flow")
runner.hass.data[ha_loader.DATA_COMPONENTS]["demo_setup"] = module
runner.hass.data[ha_loader.DATA_COMPONENTS]["demo_setup.config_flow"] = (
config_flow_module
)
runner.hass.config.components.add("phase5_demo")
runner.hass.config.components.add("demo_setup")
integration = ha_loader.Integration(
runner.hass,
"homeassistant.components.phase5_demo",
"homeassistant.components.demo_setup",
None,
{
"domain": "phase5_demo",
"name": "Phase 5 Demo",
"domain": "demo_setup",
"name": "Demo Setup",
"config_flow": True,
"documentation": "https://example.com",
"iot_class": "local_polling",
@@ -127,11 +127,11 @@ async def test_entry_setup_calls_integration_setup_entry(
runner.hass.data[ha_loader.DATA_INTEGRATIONS] = runner.hass.data.get(
ha_loader.DATA_INTEGRATIONS, {}
)
runner.hass.data[ha_loader.DATA_INTEGRATIONS]["phase5_demo"] = integration
runner.hass.data[ha_loader.DATA_INTEGRATIONS]["demo_setup"] = integration
payload = pb.EntrySetup(
entry_id="test_entry_id_5",
domain="phase5_demo",
domain="demo_setup",
title="Demo",
source="user",
version=1,
@@ -158,7 +158,7 @@ async def test_entry_setup_reports_failure_reason(
payload = pb.EntrySetup(
entry_id="missing_entry_id",
domain="phase5_missing",
domain="demo_missing",
title="Missing",
source="user",
version=1,
@@ -1,4 +1,4 @@
"""Phase 6 tests for :class:`hass_client.event_mirror.EventMirror`."""
"""Tests for :class:`hass_client.event_mirror.EventMirror`."""
import asyncio
import tempfile
@@ -1,4 +1,4 @@
"""Phase 4 tests for :class:`hass_client.flow_runner.FlowRunner`.
"""Tests for :class:`hass_client.flow_runner.FlowRunner`.
Exercises the sandbox-side flow loop against a mock integration whose
``async_setup_entry`` is intercepted before the FlowRunner is asked to
@@ -131,7 +131,7 @@ async def test_flow_init_returns_form(
assert result.type == "form"
assert result.step_id == "user"
# Phase 14: data_schema rides as the same list-of-fields shape
# data_schema rides as the same list-of-fields shape
# voluptuous_serialize.convert produces, so the proxy on main can
# rebuild a usable vol.Schema (or hand the list straight to the
# frontend).
@@ -185,7 +185,7 @@ async def test_flow_step_validation_error_returns_form(
async def test_flow_init_marshals_unique_id(
channels: tuple[Channel, Channel], runner: FlowRunner
) -> None:
"""flow_init pulls ``unique_id`` out of the live flow's context (Phase 14)."""
"""flow_init pulls ``unique_id`` out of the live flow's context."""
main, sandbox = channels
runner.register(sandbox)
main.start()
@@ -244,7 +244,7 @@ async def test_delayed_save_flushes_through_bridge(
they funnel through ``_async_handle_write_data`` -> ``_async_write_data``.
The contextvar branch must live at ``_async_write_data`` (not only
``async_save``) or these writes would silently land on the sandbox's
local disk instead of reaching main. The Phase 8 store subclass
local disk instead of reaching main. The earlier store subclass
overrode ``_async_write_data`` and masked this; deleting it surfaced the
gap.
"""
@@ -1,4 +1,4 @@
"""Phase 3 client-side tests for ``hass_client.sandbox``.
"""Client-side tests for ``hass_client.sandbox``.
The HA Core test suite owns the integration-level coverage (subprocess
spawn, restart budget, multi-group). These tests pin the runtime's
@@ -50,9 +50,9 @@ async def test_runtime_starts_in_locked_down_sharing_posture(
) -> None:
"""The sandbox HA sees only its own entities — no subscription to main.
Phase 20 dropped the unwired ``share_*`` config surface; the
locked-down posture is now a property of the runtime itself rather
than a config flag. See ``sandbox/docs/design-share-states.md``
There is no ``share_*`` config surface; the locked-down posture is
a property of the runtime itself rather than a config flag. See
``sandbox/docs/design-share-states.md``
for the future opt-in design.
"""
runtime = SandboxRuntime(
@@ -87,7 +87,7 @@ async def test_runtime_shuts_down_on_request(
url="ws://x",
group="built-in",
# Pytest captures stdin/stdout; skip channel setup for this
# in-process shutdown test (Phase 4 covers the real stdio path
# in-process shutdown test (the real stdio path is covered
# via the manager-driven subprocess tests).
channel_factory=_noop_channel_factory,
)
@@ -1,4 +1,4 @@
"""Phase 6 tests for :class:`hass_client.service_mirror.ServiceMirror`.
"""Tests for :class:`hass_client.service_mirror.ServiceMirror`.
Drives the mirror against a real sandbox-private :class:`HomeAssistant`
(via :class:`hass_client.flow_runner.FlowRunner`) and an in-memory
+6 -6
View File
@@ -1,4 +1,4 @@
"""Phase 9 tests for :class:`hass_client.sandbox.SandboxRuntime` shutdown.
"""Tests for :class:`hass_client.sandbox.SandboxRuntime` shutdown.
The runtime registers ``sandbox/shutdown`` once its channel is up.
These tests exercise:
@@ -165,7 +165,7 @@ async def test_shutdown_fires_final_write_event(
runtime_pair: tuple[SandboxRuntime, Channel, asyncio.Task[int]],
capsys: pytest.CaptureFixture[str],
) -> None:
"""Phase 12: the shutdown handler fires EVENT_HOMEASSISTANT_FINAL_WRITE.
"""The shutdown handler fires EVENT_HOMEASSISTANT_FINAL_WRITE.
Concurrent channel dispatcher means the FINAL_WRITE fire-and-drain
inside the shutdown handler no longer deadlocks against re-entrant
@@ -191,13 +191,13 @@ async def test_shutdown_flushes_pending_delay_save(
runtime_pair: tuple[SandboxRuntime, Channel, asyncio.Task[int]],
capsys: pytest.CaptureFixture[str],
) -> None:
"""Phase 12: ``async_delay_save`` writes flush through the store bridge.
"""``async_delay_save`` writes flush through the store bridge.
Without the concurrent channel dispatcher this would deadlock: the
Store's FINAL_WRITE listener would re-enter the same channel reader
that is dispatching the shutdown handler. With Phase 12 the inner
``store_save`` lands on the main-side handler while the shutdown
handler is still running.
that is dispatching the shutdown handler. The concurrent dispatcher
lets the inner ``store_save`` land on the main-side handler while the
shutdown handler is still running.
"""
runtime, main_channel, _task = runtime_pair
hass = runtime._flow_runner.hass # noqa: SLF001
@@ -1,4 +1,4 @@
"""Runtime-side transport selection + unix channel (transport T3).
"""Runtime-side transport selection + unix channel.
The HA Core suite owns the manager-driven subprocess coverage. These
tests pin the runtime side: ``--url`` scheme transport kind, the
+1 -1
View File
@@ -39,7 +39,7 @@ CORE_ROOT = _HERE.parent
CORE_TESTS_DIR = CORE_ROOT / "tests" / "components"
HASS_CLIENT_DIR = _HERE / "hass_client"
DEFAULT_RESULTS_CSV = _HERE / "COMPAT.csv"
# COMPAT.md is the curated Phase 15 baseline report and is NOT overwritten
# COMPAT.md is the curated baseline report and is NOT overwritten
# on every run. Auto-generated runs land in COMPAT_LATEST.md so reviewers
# can diff against the curated baseline.
DEFAULT_REPORT_MD = _HERE / "COMPAT_LATEST.md"
+10 -10
View File
@@ -1,6 +1,6 @@
"""Run the full sandbox compat sweep across every routable integration.
Phase 16 broadens the Phase 15 baseline (v1's 37-integration list) to cover
This sweep broadens the 37-integration baseline to cover
**every config-entry-based integration** that the sandbox classifier would
route to a sandbox. The goal of this run is not to fix anything it is
to *measure* and *categorize* so the resulting backlog is grounded in real
@@ -13,7 +13,7 @@ Discovery rules (see ``discover_integrations``):
sandbox).
- Skip the ``ALWAYS_MAIN`` set (decided by the classifier already).
- Skip integrations whose ``manifest.json`` has ``config_flow: false``
(Phase 1 scoped YAML-only integrations out).
(YAML-only integrations are out of scope).
- Skip integrations whose ``tests/components/<domain>/`` directory has no
``test_*.py`` files (no signal either way).
- Skip integrations whose source ships a platform file in
@@ -22,7 +22,7 @@ Discovery rules (see ``discover_integrations``):
For every surviving integration the runner spawns a pytest subprocess with
``-p hass_client.testing.pytest_plugin`` (the in-process plugin used by
the Phase 15 baseline), captures the JUnit XML output, and dumps the full
the baseline), captures the JUnit XML output, and dumps the full
text output + per-test failure tracebacks into the per-integration error
directory so the categorizer (``categorize_failures.py``) can bucket
every failure.
@@ -42,7 +42,7 @@ Usage::
# Restrict to a list (handy for iterating on the categorizer)
uv run python run_compat_full.py input_boolean light switch
# Validation: re-run Phase 15's 37-integration baseline
# Validation: re-run the 37-integration baseline
uv run python run_compat_full.py --baseline-37
The two committed deliverables are written at the end:
@@ -80,7 +80,7 @@ ERRORS_DIR = Path(os.environ.get("SANDBOX_ERRORS_DIR", "/tmp/sandbox_errors"))
# Mirrors homeassistant/components/sandbox/const.py. Duplicated here
# because importing the live module would require booting the core test
# env from this stand-alone driver. The Phase 2 unit tests guard against
# env from this stand-alone driver. The unit tests guard against
# behavioural drift; the per-integration sweep is allowed to lag by an
# entry — if either set changes, update both copies and re-run.
ALWAYS_MAIN: frozenset[str] = frozenset(
@@ -90,7 +90,7 @@ SANDBOX_INCOMPATIBLE_PLATFORMS: frozenset[str] = frozenset(
{"stt", "tts", "conversation", "assist_satellite", "wake_word", "camera"}
)
# Phase 15's 37-integration list, for the ``--baseline-37`` shortcut. The
# The 37-integration baseline list, for the ``--baseline-37`` shortcut. The
# list lives both here and in ``COMPAT.md``; keep them in sync.
BASELINE_37: tuple[str, ...] = (
"input_boolean", "input_button", "input_datetime", "input_number",
@@ -209,7 +209,7 @@ def _parse_junit(xml_path: Path, *, errors_dir: Path, integration: str) -> Resul
result.skipped = int(suite.attrib.get("skipped", 0))
result.duration = float(suite.attrib.get("time", 0.0))
# JUnit's ``tests`` counts every test including failures/errors/skipped.
# Re-derive ``passed`` so the dataclass meaning matches Phase 15's CSV.
# Re-derive ``passed`` so the dataclass meaning matches the baseline CSV.
result.passed = max(0, result.passed - result.failed - result.errors - result.skipped)
integration_errors_dir = errors_dir / integration
@@ -368,7 +368,7 @@ def write_report(
)
lines: list[str] = [
"# Sandbox — full compat sweep (Phase 16)",
"# Sandbox — full compat sweep",
"",
"**This file is auto-generated by `run_compat_full.py`** — re-run the",
"script to refresh it. Companion machine-readable CSV is `COMPAT_FULL.csv`,",
@@ -385,7 +385,7 @@ def write_report(
"",
"## Discovery",
"",
"Walked `homeassistant/components/`, applied the Phase 16 filters:",
"Walked `homeassistant/components/`, applied the discovery filters:",
"",
"| Filter | Skipped |",
"| --- | ---: |",
@@ -486,7 +486,7 @@ def main(argv: list[str] | None = None) -> int:
)
parser.add_argument(
"--baseline-37", action="store_true",
help="Restrict to Phase 15's 37-integration baseline list.",
help="Restrict to the 37-integration baseline list.",
)
parser.add_argument(
"--concurrency", type=int, default=4,
+1 -1
View File
@@ -1,4 +1,4 @@
"""Shared helpers for Phase 4 sandbox tests.
"""Shared helpers for sandbox tests.
Provides:
+16 -16
View File
@@ -1,4 +1,4 @@
"""Tests for the Phase 5 :class:`SandboxBridge` — main-side entity bridge."""
"""Tests for the :class:`SandboxBridge` — main-side entity bridge."""
import asyncio
from typing import Any
@@ -41,8 +41,8 @@ async def _wire(
@pytest.fixture
def ignore_translations_for_mock_domains() -> list[str]:
"""Suppress strings.json checks for the Phase 6 mock domains."""
return ["phase6_demo", "phase6_local"]
"""Suppress strings.json checks for the service-mirror mock domains."""
return ["mirror_demo", "mirror_local"]
@pytest.fixture(name="entry")
@@ -553,23 +553,23 @@ async def test_register_service_installs_forwarder(hass: HomeAssistant) -> None:
result = await sandbox_channel.call(
"sandbox/register_service",
pb.RegisterService(
domain="phase6_demo",
domain="mirror_demo",
service="do_thing",
supports_response="none",
),
)
assert result.installed is True
assert hass.services.has_service("phase6_demo", "do_thing")
assert hass.services.has_service("mirror_demo", "do_thing")
await hass.services.async_call(
"phase6_demo", "do_thing", {"foo": "bar"}, blocking=True
"mirror_demo", "do_thing", {"foo": "bar"}, blocking=True
)
finally:
await main_channel.close()
await sandbox_channel.close()
assert len(seen_calls) == 1
assert seen_calls[0].domain == "phase6_demo"
assert seen_calls[0].domain == "mirror_demo"
assert seen_calls[0].service == "do_thing"
assert struct_to_dict(seen_calls[0].service_data) == {"foo": "bar"}
@@ -614,14 +614,14 @@ async def test_forwarded_context_restores_on_echoed_state(
await sandbox_channel.call(
"sandbox/register_service",
pb.RegisterService(
domain="phase6_demo", service="do_thing", supports_response="none"
domain="mirror_demo", service="do_thing", supports_response="none"
),
)
# The user who pressed the button that triggered the sandboxed action.
user_context = Context(user_id="user-1", parent_id="parent-1")
await hass.services.async_call(
"phase6_demo", "do_thing", {}, blocking=True, context=user_context
"mirror_demo", "do_thing", {}, blocking=True, context=user_context
)
assert forwarded_ids == [user_context.id]
@@ -659,13 +659,13 @@ async def test_register_service_skips_existing_handler(
async def _local(_call: Any) -> None:
return None
hass.services.async_register("phase6_local", "noop", _local)
hass.services.async_register("mirror_local", "noop", _local)
try:
result = await sandbox_channel.call(
"sandbox/register_service",
pb.RegisterService(
domain="phase6_local",
domain="mirror_local",
service="noop",
supports_response="none",
),
@@ -676,7 +676,7 @@ async def test_register_service_skips_existing_handler(
assert result.installed is False
# The existing handler is still in place — the bridge didn't replace it.
assert hass.services.has_service("phase6_local", "noop")
assert hass.services.has_service("mirror_local", "noop")
async def test_unregister_service_removes_forwarder(
@@ -689,23 +689,23 @@ async def test_unregister_service_removes_forwarder(
await sandbox_channel.call(
"sandbox/register_service",
pb.RegisterService(
domain="phase6_demo",
domain="mirror_demo",
service="stop",
supports_response="none",
),
)
assert hass.services.has_service("phase6_demo", "stop")
assert hass.services.has_service("mirror_demo", "stop")
result = await sandbox_channel.call(
"sandbox/unregister_service",
pb.UnregisterService(domain="phase6_demo", service="stop"),
pb.UnregisterService(domain="mirror_demo", service="stop"),
)
finally:
await main_channel.close()
await sandbox_channel.close()
assert result.removed is True
assert not hass.services.has_service("phase6_demo", "stop")
assert not hass.services.has_service("mirror_demo", "stop")
async def test_fire_event_lands_on_main_bus(hass: HomeAssistant) -> None:
+1 -1
View File
@@ -193,7 +193,7 @@ async def test_push_message_is_one_way(channels: tuple) -> None:
async def test_handler_can_call_back_without_deadlock(channels: tuple) -> None:
"""A handler that issues channel.call mid-execution doesn't deadlock.
Phase 12: dispatch runs in a task so the reader keeps draining the
Dispatch runs in a task so the reader keeps draining the
wire the nested call's reply can be picked up while the outer
handler is still suspended.
"""
+2 -2
View File
@@ -96,7 +96,7 @@ async def test_always_main_domains_pin_to_main(
async def test_phase1_spike_late_additions_pin_to_main(
hass: HomeAssistant, domain: str
) -> None:
"""ai_task and image were folded into ALWAYS_MAIN by the Phase 1 spike.
"""ai_task and image are folded into ALWAYS_MAIN.
Pinned as their own test so the regression message is unambiguous if
someone removes them from the deny-list without reading the decision doc.
@@ -152,7 +152,7 @@ async def test_each_incompatible_platform_forces_main(
async def test_image_is_domain_not_platform_level(hass: HomeAssistant) -> None:
"""Phase 1 decision: `image` lives in ALWAYS_MAIN, not the platform list.
"""`image` lives in ALWAYS_MAIN, not the platform list.
Camera covers the bytes-platform case; image entities returning bytes
drive the domain-level rule. Lock the shape so a future cleanup doesn't
@@ -1,4 +1,4 @@
"""Phase 19 tests — device_info bridging from the sandbox to main's registries."""
"""Tests — device_info bridging from the sandbox to main's registries."""
from typing import Any
@@ -180,7 +180,7 @@ async def test_area_assignment_propagates_to_proxy(
assert device is not None
dr.async_get(hass).async_update_device(device.id, area_id=area.id)
# The proxy's entity_registry entry inherits area through HA's standard
# device → entity area-resolution path (no Phase 19 code involvement).
# device → entity area-resolution path (no sandbox code involvement).
refreshed_device = dr.async_get(hass).async_get(device.id)
assert refreshed_device is not None
assert refreshed_device.area_id == area.id
@@ -1,4 +1,4 @@
"""Per-domain smoke tests for the 28 Phase-13 proxy entities.
"""Per-domain smoke tests for the 28 proxy entities.
Each parametrised case:
@@ -11,7 +11,7 @@ Each parametrised case:
``sandbox/call_service`` RPC carries the expected ``(domain,
service)`` plus an entity-targeted target list.
The 4 proxies that already shipped in Phase 5 (light / switch / sensor /
The 4 "rich" proxies (light / switch / sensor /
binary_sensor) have dedicated coverage in ``test_bridge.py``; this file
holds the 28 additions plus ``scene`` (which is in ``ALWAYS_MAIN`` but
still ships a proxy for symmetry).
@@ -381,7 +381,7 @@ async def test_phase13_proxy_smoke(
method_kwargs: dict[str, Any],
expected_service: str,
) -> None:
"""Each Phase-13 proxy registers, accepts state, and translates a method."""
"""Each proxy registers, accepts state, and translates a method."""
bridge, main_channel, sandbox_channel = await _wire(hass)
calls: list[pb.CallService] = []
+1 -1
View File
@@ -1,4 +1,4 @@
"""Phase 3 tests for the sandbox lifecycle manager.
"""Tests for the sandbox lifecycle manager.
These exercise the real subprocess machinery the runtime entry point at
``python -m hass_client.sandbox`` is spawned for the happy path; the
+4 -4
View File
@@ -1,6 +1,6 @@
"""Phase 14 perf benchmark — 200-light area call through the bridge batcher.
"""Perf benchmark — 200-light area call through the bridge batcher.
Validates the Phase 5 :class:`_CallServiceBatcher` coalesces a 200-entity
Validates the :class:`_CallServiceBatcher` coalesces a 200-entity
area-targeted ``light.turn_on`` into a single
``sandbox/call_service`` round-trip with sub-100 ms latency.
@@ -42,8 +42,8 @@ from tests.common import MockConfigEntry
# Total number of sandbox-resident lights pushed into the bridge.
_LIGHT_COUNT = 200
# Wall-clock bar for the area call. The Phase 1 spike measured Option B
# at ~64 ms / 100 entities in-process; the batcher should compress the
# Wall-clock bar for the area call. The entity-bridge spike measured
# Option B at ~64 ms / 100 entities in-process; the batcher should compress the
# 200-entity area call into one RPC, so we budget 500 ms on the
# generous end to absorb slow CI shared runners. If we ever exceed this
# bar, either the batcher regressed or the channel grew per-call
@@ -1,6 +1,6 @@
"""T2 transport tests: ProtobufCodec round-trips + the Context security model.
"""Transport tests: ProtobufCodec round-trips + the Context security model.
Covers the guarantees the protobuf wire adds on top of T1:
Covers the guarantees the protobuf wire adds:
* a frame survives an encode decode re-encode cycle byte-identically (no
field drops), including fidelity #7's structured voluptuous error data;
+2 -2
View File
@@ -156,8 +156,8 @@ async def test_full_flow_user_to_create_entry(
assert struct_to_dict(stub.step_calls[0].user_input) == {"host": "1.2.3.4"}
# The new ConfigEntry is tagged with the sandbox group via the
# ConfigEntry.sandbox first-class field (Phase 17 — keeps the tag
# off entry.data where integration tests assert on it).
# ConfigEntry.sandbox first-class field (keeps the tag off entry.data
# where integration tests assert on it).
entries = hass.config_entries.async_entries("test_proxy_full")
assert len(entries) == 1
assert entries[0].sandbox == "built-in"
@@ -1,6 +1,6 @@
"""Phase 14 follow-ups for sandbox.
"""Schema bridging, unique_id propagation, and the unload hook.
Covers four pieces that Phase 5 / 6 deferred and Phase 14 fills in:
Covers four pieces:
* The serialised :class:`vol.Schema` bridge for flow forms and mirrored
services (the proxy reconstructs a usable schema from the wire shape).
@@ -99,14 +99,14 @@ def _wired_sandbox(
@pytest.fixture
def ignore_translations_for_mock_domains() -> list[str]:
"""Suppress strings.json checks for the Phase 14 mock domains."""
"""Suppress strings.json checks for the mock domains."""
return [
"phase14_schema",
"phase14_unique",
"phase14_duplicate",
"phase14_unload",
"phase14_local",
"phase14_svc",
"mock_schema",
"mock_unique",
"mock_duplicate",
"mock_unload",
"mock_local",
"mock_svc",
]
@@ -214,14 +214,14 @@ async def test_flow_form_renders_reconstructed_schema(
hass: HomeAssistant, manager: FakeSandboxManager
) -> None:
"""A FORM with a serialised data_schema arrives on main with the schema."""
mock_integration(hass, MockModule("phase14_schema"))
mock_integration(hass, MockModule("mock_schema"))
serialized_schema = [
{"name": "host", "type": "string", "required": True},
]
form = pb.FlowResult(
type=FlowResultType.FORM.value,
flow_id="sandbox-flow-schema",
handler="phase14_schema",
handler="mock_schema",
step_id="user",
)
form.data_schema.extend(serialized_schema)
@@ -236,7 +236,7 @@ async def test_flow_form_renders_reconstructed_schema(
):
await _install_router(hass, manager)
result = await hass.config_entries.flow.async_init(
"phase14_schema", context={"source": SOURCE_USER}
"mock_schema", context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
@@ -258,7 +258,7 @@ async def test_register_service_with_schema_validates_on_main(
) -> None:
"""Sandbox-mirrored service uses its reconstructed schema on main calls."""
main_channel, sandbox_channel = make_channel_pair(
name_a="main-phase14", name_b="sandbox-phase14"
name_a="main-mock", name_b="sandbox-mock"
)
bridge = SandboxBridge(hass, group="built-in", channel=main_channel)
main_channel.start()
@@ -277,7 +277,7 @@ async def test_register_service_with_schema_validates_on_main(
]
register_service = pb.RegisterService(
domain="phase14_svc",
domain="mock_svc",
service="do_thing",
supports_response="none",
)
@@ -291,12 +291,12 @@ async def test_register_service_with_schema_validates_on_main(
with pytest.raises(vol.Invalid):
await hass.services.async_call(
"phase14_svc", "do_thing", {"wrong": "field"}, blocking=True
"mock_svc", "do_thing", {"wrong": "field"}, blocking=True
)
assert seen == []
await hass.services.async_call(
"phase14_svc", "do_thing", {"host": "1.2.3.4"}, blocking=True
"mock_svc", "do_thing", {"host": "1.2.3.4"}, blocking=True
)
assert len(seen) == 1
assert struct_to_dict(seen[0].service_data) == {"host": "1.2.3.4"}
@@ -315,11 +315,11 @@ async def test_unique_id_propagates_to_proxy_context(
hass: HomeAssistant, manager: FakeSandboxManager
) -> None:
"""A sandbox-side ``unique_id`` is mirrored onto the proxy's context."""
mock_integration(hass, MockModule("phase14_unique"))
mock_integration(hass, MockModule("mock_unique"))
form = pb.FlowResult(
type=FlowResultType.FORM.value,
flow_id="sandbox-flow-uid",
handler="phase14_unique",
handler="mock_unique",
step_id="user",
)
form.context.update({"source": SOURCE_USER, "unique_id": "abc-123"})
@@ -334,11 +334,11 @@ async def test_unique_id_propagates_to_proxy_context(
):
await _install_router(hass, manager)
result = await hass.config_entries.flow.async_init(
"phase14_unique", context={"source": SOURCE_USER}
"mock_unique", context={"source": SOURCE_USER}
)
# The framework now reads unique_id off the proxy's context;
# ``async_progress_by_handler`` surfaces it for duplicate checks.
progress = hass.config_entries.flow.async_progress_by_handler("phase14_unique")
progress = hass.config_entries.flow.async_progress_by_handler("mock_unique")
assert result["type"] is FlowResultType.FORM
assert len(progress) == 1
@@ -349,11 +349,11 @@ async def test_duplicate_unique_id_aborts_second_flow(
hass: HomeAssistant, manager: FakeSandboxManager
) -> None:
"""A second flow with the same propagated unique_id aborts on main."""
mock_integration(hass, MockModule("phase14_duplicate"))
mock_integration(hass, MockModule("mock_duplicate"))
form_a = pb.FlowResult(
type=FlowResultType.FORM.value,
flow_id="sandbox-flow-dup-a",
handler="phase14_duplicate",
handler="mock_duplicate",
step_id="user",
)
form_a.context.update({"source": SOURCE_USER, "unique_id": "dup-1"})
@@ -361,7 +361,7 @@ async def test_duplicate_unique_id_aborts_second_flow(
form_b = pb.FlowResult(
type=FlowResultType.FORM.value,
flow_id="sandbox-flow-dup-b",
handler="phase14_duplicate",
handler="mock_duplicate",
step_id="user",
)
form_b.context.update({"source": SOURCE_USER, "unique_id": "dup-1"})
@@ -376,13 +376,13 @@ async def test_duplicate_unique_id_aborts_second_flow(
):
await _install_router(hass, manager)
first = await hass.config_entries.flow.async_init(
"phase14_duplicate", context={"source": SOURCE_USER}
"mock_duplicate", context={"source": SOURCE_USER}
)
# The framework's duplicate-detection guard fires inside the
# second `async_init`. See `_check_in_progress_by_unique_id`.
try:
second = await hass.config_entries.flow.async_init(
"phase14_duplicate", context={"source": SOURCE_USER}
"mock_duplicate", context={"source": SOURCE_USER}
)
except AbortFlow as err:
second = {
@@ -406,7 +406,7 @@ async def test_async_unload_consults_router_for_sandboxed_entry(
hass: HomeAssistant, manager: FakeSandboxManager
) -> None:
"""ConfigEntries.async_unload calls the router for sandbox-tagged entries."""
mock_integration(hass, MockModule("phase14_unload"))
mock_integration(hass, MockModule("mock_unload"))
with (
_wired_sandbox(manager, group="built-in", responses=[]) as stub,
patch(
@@ -416,7 +416,7 @@ async def test_async_unload_consults_router_for_sandboxed_entry(
):
await _install_router(hass, manager)
entry = MockConfigEntry(
domain="phase14_unload",
domain="mock_unload",
data={"host": "1.2.3.4"},
sandbox="built-in",
)
@@ -444,11 +444,11 @@ async def test_async_unload_falls_through_for_non_sandboxed_entry(
mock_integration(
hass,
MockModule("phase14_local", async_unload_entry=_async_unload_entry),
MockModule("mock_local", async_unload_entry=_async_unload_entry),
)
await _install_router(hass, manager)
entry = MockConfigEntry(domain="phase14_local", data={"host": "1.2.3.4"})
entry = MockConfigEntry(domain="mock_local", data={"host": "1.2.3.4"})
entry.add_to_hass(hass)
# Mark loaded directly; we're not exercising async_setup here.
entry.mock_state(hass, ConfigEntryState.LOADED)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Phase 9 tests for graceful shutdown orchestration.
"""Tests for graceful shutdown orchestration.
The main side spawns the real ``python -m hass_client.sandbox``
runtime, calls :meth:`SandboxManager.async_graceful_shutdown_all`, and
+3 -3
View File
@@ -1,4 +1,4 @@
"""Phase 1 spike — compare Options A and B on a 100-light area call.
"""Entity-bridge spike — compare Options A and B on a 100-light area call.
Each test:
@@ -107,7 +107,7 @@ async def test_option_a_correctness_and_latency(
"""Option A: method-forward RPC must work and stay under the budget."""
result = await _measure("A", main_hass, sandbox_hass)
pytest.option_a_result = result # type: ignore[attr-defined]
# Generous bound — Phase 1 plan target is ~50ms for 100 entities.
# Generous bound — the target is ~50ms for 100 entities.
assert result["median"] < 1.0, f"Option A median too slow: {result}"
@@ -139,7 +139,7 @@ async def test_report_comparison() -> None:
await sandbox_hass.async_stop(force=True)
await main_hass.async_stop(force=True)
print("\n=== Phase 1 spike — light.turn_on area call ===")
print("\n=== Entity-bridge spike — light.turn_on area call ===")
print(f"Entities: {LIGHT_COUNT}, iterations: {ITERATIONS}\n")
print(
f"{'option':<8}{'median (ms)':>14}{'min (ms)':>12}"
+7 -7
View File
@@ -1,4 +1,4 @@
"""Phase 8 tests for the main-side Store handlers on :class:`SandboxBridge`.
"""Tests for the main-side Store handlers on :class:`SandboxBridge`.
We exercise the three ``sandbox/store_*`` handlers via the in-memory
channel pair, with the bridge wired against the real ``hass`` config
@@ -53,10 +53,10 @@ async def test_store_save_writes_to_namespaced_path(hass: HomeAssistant) -> None
wrapped = {
"version": 1,
"minor_version": 1,
"key": "phase8_demo",
"key": "demo_key",
"data": {"hello": "world"},
}
save = pb.StoreSave(key="phase8_demo")
save = pb.StoreSave(key="demo_key")
save.data.update(wrapped)
try:
result = await sandbox_channel.call("sandbox/store_save", save)
@@ -65,7 +65,7 @@ async def test_store_save_writes_to_namespaced_path(hass: HomeAssistant) -> None
await sandbox_channel.close()
assert result.ok
path = _store_path(hass, "built-in", "phase8_demo")
path = _store_path(hass, "built-in", "demo_key")
assert path.is_file()
# The file holds the wrapped Store payload verbatim.
assert json.loads(path.read_text(encoding="utf-8")) == wrapped
@@ -77,15 +77,15 @@ async def test_store_load_returns_saved_payload(hass: HomeAssistant) -> None:
wrapped = {
"version": 2,
"minor_version": 3,
"key": "phase8_demo",
"key": "demo_key",
"data": {"counter": 42},
}
save = pb.StoreSave(key="phase8_demo")
save = pb.StoreSave(key="demo_key")
save.data.update(wrapped)
try:
await sandbox_channel.call("sandbox/store_save", save)
loaded = await sandbox_channel.call(
"sandbox/store_load", pb.StoreLoad(key="phase8_demo")
"sandbox/store_load", pb.StoreLoad(key="demo_key")
)
finally:
await main_channel.close()
+1 -1
View File
@@ -1,4 +1,4 @@
"""End-to-end subprocess tests for Phase 4.
"""End-to-end subprocess tests.
Spawns the real ``python -m hass_client.sandbox`` runtime and exercises
the JSON-line control channel: handshake ping round-trip graceful
@@ -1,4 +1,4 @@
"""Phase 10 tests: the ``hass_client.testing`` pytest plugins.
"""Tests for the ``hass_client.testing`` pytest plugins.
Two plugins are exercised here:
@@ -11,7 +11,7 @@ Two plugins are exercised here:
with the real-subprocess sandbox.
The real-subprocess fixture itself is covered by
:mod:`test_phase4_subprocess` which already drives ``SandboxManager``
:mod:`test_subprocess` which already drives ``SandboxManager``
through a real subprocess; the tests here unit-check the hook shape
without spawning a nested pytest run.
"""
@@ -159,7 +159,7 @@ def test_autotag_sets_mock_config_entry_sandbox() -> None:
assert entry.sandbox is None
entry.add_to_hass(fake_hass)
assert entry.sandbox == "built-in"
# entry.data is untouched — this is the whole point of Phase 17.
# entry.data is untouched — the tag rides on entry.sandbox instead.
assert dict(entry.data) == {"foo": "bar"}
assert fake_hass.config_entries._entries == {entry.entry_id: entry}
@@ -1,4 +1,4 @@
"""Unix-socket control-channel transport (transport T3).
"""Unix-socket control-channel transport.
Spawns the real ``python -m hass_client.sandbox`` runtime with the
manager configured for the unix-socket transport: the manager opens a