Skip to content

Phase 1 — ship schema_0 as its own distribution - #151

Merged
cayossarian merged 9 commits into
developfrom
feat/schema-adapter-phase-1
Aug 4, 2026
Merged

Phase 1 — ship schema_0 as its own distribution#151
cayossarian merged 9 commits into
developfrom
feat/schema-adapter-phase-1

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Phase 1 of the schema-adapter isolation work. Targets develop, not main — this is prototype work until it is proven end to end against an integration prototype.

Plan: SpanPanel_Docs/span-panel-api/2026-08-03-schema-adapter-phase-1-plan.md

What this delivers

Phase 0 proved the transport could delegate all parsing to a SchemaAdapter. It did not prove the parser could be absent — the bootstrap still imported _impl/schema_0 in three places, so span-panel-api could not be installed without its parser. That is the configuration entry-point discovery exists to support, and it now works.

The acceptance test is not a unit test. scripts/verify_adapterless_install.py runs against a venv holding only the bootstrap wheel and asserts the transport imports, discovery is empty, and building a parser raises SpanPanelAdapterMissingError by name rather than ModuleNotFoundError. It runs in CI. A test in the dev workspace can never observe this failure, because the adapter is always importable there.

Verified locally end to end: bootstrap wheel alone → imports and fails by name; add the adapter wheel → discovery, construction and topic generation all resolve.

Commits, in dependency order

The three bootstrap → _impl edges are severed before the code moves. Sever first and each removal is a small reviewable diff against a working tree; move first and every import error is ambiguous between "the split is wrong" and "this edge was never severed."

Commit What
93b206b Remove the three flat-schema names from the public API (severs 2 of 3 edges)
590c6ff Resolve the default adapter through discovery (severs the 3rd)
cac27c0 Refuse an unreadable data-model-version instead of assuming flat
fc209c2 Validate entry points before registering them
6308acd Phase 1 small items
48aef8a Ship schema_0 as its own distribution
3ca7835 Measure adapter-package coverage after the split
fa6dd8a Provenance tests (design doc testing item 8a/8b)
1a29990 Changelogs

Breaking changes

  1. span-panel-api contains no parser. Flat-schema panels need span-panel-api-schema-0 installed alongside it.
  2. HomieLifecycle, HomiePropertyAccumulator, HomieDeviceConsumer are no longer exported. All three are flat-schema-specific, not Homie-convention-level — verified against every known consumer, which references none of them. They live in span_panel_api_schema_0 now.

The HA integration imports nine names from span_panel_api, all of which stay in the bootstrap and none of which move. That is independent evidence the seam was drawn in the right place.

Worth reviewer attention

  • SchemaAdapter.__init__ is now declared on the protocol. Construction was always part of the contract — the transport resolves a class from the registry and calls it — but Phase 0's Callable[[str, int], SchemaAdapter] alias left the signature unchecked against implementations. mypy caught this the moment the seam became a type[]. The signature carries panel_size, a flat-schema concept, and is the part of the protocol expected to change with schema_1; stating it makes that a visible protocol break rather than a runtime TypeError.
  • Dispatch now has three cases, not two. Absence still means flat (a real signal). A non-canonical but unambiguous major (1, 1.0-beta) dispatches on that major and logs. Only a value with no extractable major raises. Previously all of these fell through to the flat parser, which does not fail — it produces plausible but wrong power and energy figures in HA, which is strictly worse than an error.
  • pylint's wrong-import-order is disabled. pylint offers known-standard-library and known-third-party but no known-first-party, so it cannot be told the repo now has two source roots and disagreed with ruff on every adapter module. ruff's isort enforces the same rule and can be told, so it becomes the single authority.
  • The coverage fix (3ca7835) is not cosmetic. The local hook passed an explicit --cov=src/span_panel_api, so moving the adapter out silently dropped 543 statements from the report — coverage read 91.6% while the entire flat parser went unmeasured.

Checks

426 tests passing, all pre-commit hooks green (mypy strict, ruff, pylint, bandit, vulture), coverage 94%.

BREAKING CHANGE: HomieLifecycle, HomiePropertyAccumulator and
HomieDeviceConsumer are no longer exported from span_panel_api or
span_panel_api.mqtt.

All three are flat-schema-specific, not Homie-convention-level:
HomiePropertyAccumulator filters every topic against a single device's
prefix and stores node -> prop, which drops nearly every message under
parent/child; HomieLifecycle's members are not Homie 5 $state values but
a consumer-side progression encoding 'one description received => ready',
which is the flat readiness model. HomieDeviceConsumer is the flat parser
itself.

They were re-exported only because the bootstrap re-exported them; nothing
consumes them (the HA integration references none of the three, and this
repo's own tests import them from their defining modules). Removing them
severs two of the three bootstrap -> _impl edges that prevent shipping
schema_0 as a separate distribution. 3.0 is already a breaking bump.

test_public_api_unchanged.py is a two-way pin, so it is edited here in the
same commit; its docstring now frames it as a deliberate-change guard
rather than a no-change guard.
The transport's last import of a parsing implementation. mqtt/client.py
imported SchemaZeroAdapter purely to use as the default adapter_factory,
which is unsupportable once schema_0 ships as a separate distribution:
the import would fail in exactly the adapter-less install that entry-point
discovery exists to support.

adapter_factory becomes optional. When it is None, _build_adapter resolves
DEFAULT_ADAPTER_KEY through discover_adapters() and raises
SpanPanelAdapterMissingError if nothing answers to it. Resolution is lazy
by design: constructing a client must not require an adapter to be
installed, only building a parser must — so 'import
span_panel_api.mqtt.client' now succeeds with every schema_0 module
blocked, verified directly.

Behaviour is unchanged for every existing caller. A directly constructed
client still parses the flat schema; it just reaches the parser by name
rather than by import. resolve_adapter() moves to adapters.py (client
cannot import from factory — factory imports client) and factory's
_resolve_adapter_cls now delegates to it, so a missing adapter produces
one error message from one place.

Also declares SchemaAdapter.__init__. Construction was always part of the
contract — the transport resolves an adapter class and calls it — but
Phase 0's Callable[[str, int], SchemaAdapter] alias left the signature
unchecked against implementations; mypy caught this the moment the seam
became a type[]. The signature carries panel_size, a flat-schema concept,
and is the part of the protocol expected to change with schema_1; stating
it makes that a visible break rather than a runtime TypeError.
…ming flat

_select_adapter_key mapped every unparseable value to schema_0 with the
reason 'assuming flat'. A panel publishing '1', 'v1.0' or '1.0-beta' would
therefore be handed to the flat parser, which does not fail — it produces
plausible but wrong power and energy figures in Home Assistant. A wrong
number the user cannot see is strictly worse than an error they can.

Dispatch now distinguishes three cases rather than two:

- Absent: the flat-schema signal, unchanged. The property was introduced by
  the firmware that introduced parent/child, so absence is real evidence and
  must stay non-fatal — it is the common case in the field today.
- Present with an extractable major, canonical or not ('1', '1.0-beta'):
  dispatch on that major and log the deviation. This is not a guess; the
  major is what selects the adapter and it was read, not assumed. Refusing
  here would take a panel offline over a formatting difference, while the
  warning still surfaces a new firmware format before it becomes an outage.
- Present with no extractable major: raise.

Adds SpanPanelSchemaVersionError rather than reusing
SpanPanelAdapterMissingError, because the remedies differ. A missing adapter
is a known schema with no installed parser — install the package. This is a
schema whose major cannot be determined, so no adapter can even be named.

Dead in Phase 0 (data_model_version is hardcoded None) and live the moment
Tier 1 reads a real value.
discover_adapters stored whatever ep.load() returned without looking at
it. A module, function or instance registered where a class belongs passed
straight through resolve_adapter and failed later as an opaque TypeError
deep inside connect() — the failure mode SpanPanelAdapterMissingError was
introduced to prevent. It was also an Any crossing into a
dict[str, type[SchemaAdapter]], against the repo's no-Any standard.

Validation is a TypeGuard, so the Any from ep.load() is narrowed by a real
runtime check rather than assigned unexamined. The required-member list is
derived from SchemaAdapter itself rather than restated, so adding a method
to the protocol automatically makes it required of every adapter package;
issubclass is unavailable because the protocol has data members and
runtime_checkable rejects issubclass() for those.

The check is presence-only and deliberately so — a Protocol cannot express
signatures at runtime, so wrong arity still surfaces at call time. It
catches the failure that actually happens, which is a misdirected entry
point, and converts it to a named logged skip.

Skipped, never raised: one broken third-party adapter must not take down a
panel whose own adapter is installed and fine. Unreachable while schema_0
is the only registered adapter; live as soon as a second one ships.
Diagnostics move into the SpanMqttClient constructor. The factory patched
_data_model_version and _schema_dispatch_reason onto private state after
construction, which needed two protected-access disables and left a window
where a connected client could report a selected adapter alongside
schema_dispatch_reason='not dispatched'. They are now true from the moment
the object exists; constructing directly still describes exactly that.

SUPPORTS_DATA_MODEL_VERSIONS had two independent literals — the class
attribute the protocol requires and a module constant beside it — with
nothing asserting they agreed. The module now re-exports the class
attribute, so the class is the single source. A drift here would have been
invisible until a panel reported a version the adapter falsely claimed.

Exports SpanPanelAdapterMissingError and SpanPanelSchemaVersionError. Both
are errors a user actually sees when their panel outruns their install, so
catching them should not require reaching into a private module.

Deletes DEVICE_TOPIC_FMT, STATE_TOPIC_FMT, DESCRIPTION_TOPIC_FMT and
PROPERTY_TOPIC_FMT (dead before Phase 0 relocated them; the adapter reads
through one wildcard subscription and writes through the set pattern) and
TYPE_PCS (a real schema type this library does not consume).

Documents the two type namespaces in const.py, which are easy to conflate:
the schema's "types" block declares properties per type, while a node's
$description carries the type string actually on the wire, and they are
not the same set. TYPE_LUGS_UPSTREAM/DOWNSTREAM are real wire types
confirmed against a live panel in 1eef0dc but are absent from the schema,
which declares only the base lugs type — so each needs a _LUGS_FALLBACK
alias or property metadata silently comes back empty. Corrects the stale
"kept in sync with homie.py" reference to consumer.py.
BREAKING CHANGE: span-panel-api no longer contains a parser. Installing it
alone gives a client that connects and then raises
SpanPanelAdapterMissingError. Flat-schema panels need
span-panel-api-schema-0 installed alongside it.

This is what Phase 0's protocol seam was for. Phase 0 proved the transport
could delegate all parsing to a SchemaAdapter; it did not prove the parser
could be absent, because the bootstrap still imported _impl/schema_0 in
three places. Those were severed in the preceding commits, so the code can
now actually move.

Layout is a uv workspace: the bootstrap stays at src/span_panel_api, the
adapter becomes packages/schema-0 publishing span-panel-api-schema-0 with
its own version and README. The entry-point block moves from the root
pyproject to the adapter's — that single move is what makes the bootstrap
adapter-less; everything else is import rewriting. git mv keeps rename
detection, so the diff reads as a move (const.py is byte-identical).

Adds scripts/verify_adapterless_install.py and a CI step that runs it
against a venv holding only the bootstrap wheel. This cannot be a unit
test: the thing under test is installed distribution metadata — which
wheel carries the entry point, and whether the import graph reaches a
parser — and a test in the development workspace always has the adapter
importable, so it can never observe the failure it would be guarding.
Verified locally end to end: bootstrap alone imports and fails by name;
adding the adapter wheel makes discovery, construction and topic
generation resolve.

Two tool configs had to learn the repo has two source roots:

- vulture scanned only src/span_panel_api, so moving the adapter out made
  its protocol parameters look unused. It now scans both trees.
- pylint's wrong-import-order is disabled. pylint offers
  known-standard-library and known-third-party but no known-first-party, so
  it cannot be told that span_panel_api_schema_0 is first-party and
  disagreed with ruff on every adapter module. ruff's isort enforces the
  same rule and can be told the truth via known-first-party, so it becomes
  the single authority.

Versions go to 3.0.0b1 / 1.0.0b1 because the adapter declares a dependency
on the bootstrap and needs a real version to resolve against.
The local coverage hook passed --cov=src/span_panel_api explicitly, so
moving the adapter out of src/ silently dropped 543 statements from the
report — coverage looked fine at 91.6% while the entire flat parser went
unmeasured. Both source roots are now passed, and the real figure is 94%.
…ource

Design doc testing item 8, clauses 8a and 8b — the only signal that catches
adapter-axis drift before release. Every other symptom of "SPAN changed the
schema and we did not notice" reaches production as a silent absence: a
property that stops arriving, a metadata lookup that returns None, an entity
that goes unavailable with no error anywhere. The same failure already
happened upstream (python-sdk#27 was exactly a hardcoded fact that had
stopped resolving), which is why it is worth having with one adapter rather
than waiting for schema_1.

8b records the schema revision this adapter was written against
(sha256:d347556a07d98f40, spanos2/r202603/05) as SCHEMA_ANCHOR in the
adapter package rather than the bootstrap, because the field is renamed with
the block it covers: flat serves typesSchemaHash over `types`, parent/child
serves deviceClassesSchemaHash over `deviceClasses`. schema_1 declares its
own. The hash is content-derived, so it moves when the schema moves rather
than on every firmware build, which is what makes it an anchor and not noise.

8a checks all 64 (node_type, property_id) rows in _PROPERTY_FIELD_MAP
through the same lookup path build_field_metadata uses, plus HOMIE_DOMAIN /
HOMIE_VERSION against homieDomain / homieVersion. It also pins the two type
namespaces apart: TYPE_LUGS_UPSTREAM and TYPE_LUGS_DOWNSTREAM are real wire
types that the schema does not declare, so they are asserted *absent* from
`types` and *present* in _LUGS_FALLBACK — a wire-only subtype without an
alias silently yields no property metadata, and that is now caught.

Records one standing disagreement as an assertion rather than a comment: the
schema declares circuit active-power in kW and real panels publish W. The
instinct on finding that is to "fix" the code back to kW, which would
reintroduce the 1000x error 1eef0dc removed after checking real hardware.
The test fails if SPAN ever corrects the schema, and says to delete itself.

8c (does SUPPORTS_DATA_MODEL_VERSIONS still cover the reported version) is
deliberately absent: flat firmware publishes no version to compare against.
Records the two breaking changes users will hit — the bootstrap no longer
containing a parser, and the three flat-schema names leaving the public API
— with the install command that resolves the first.

The adapter changelog opens by stating which axis it versions on. That
package's number tracks the parser, never the wire format it parses; the
wire format is fixed and identified by SUPPORTS_DATA_MODEL_VERSIONS.
Confusing the two is the failure mode the two-axis split exists to prevent,
so it is worth saying in the file people read when deciding to upgrade.

Also records the two known deviations from the published schema (circuit
active-power in W not kW, and the two undeclared lugs subtypes) where a
consumer will actually look for them.
@cayossarian
cayossarian merged commit 7551704 into develop Aug 4, 2026
5 checks passed
@cayossarian
cayossarian deleted the feat/schema-adapter-phase-1 branch August 4, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant