From a598d52146268bf760d1fbfbb8af106ba4f8303b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:28:08 -0700 Subject: [PATCH 1/2] fix: make the release publishable and the adapter type-visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow was never updated for the two-distribution layout. It built the root package only and rewrote the root version from the tag, so publishing 3.0.0b1 would have shipped a bootstrap with no adapter on PyPI — every install connecting and then raising SpanPanelAdapterMissingError, with the package that fixes it not existing. A tag now selects a distribution rather than setting a version: vX.Y.Z -> span-panel-api schema-N-vX.Y.Z -> span-panel-api-schema-N and the job fails unless the tag matches the version committed in that distribution's pyproject.toml. Stamping the version at release time is not extensible to two packages and is now actively wrong: the adapter declares a dependency floor on the bootstrap, so both committed versions participate in resolution and cannot be treated as placeholders. The adapter distribution shipped without a py.typed marker — it did not travel with the code when the parser moved out of src/span_panel_api, which has one. Fully annotated, strict-clean code resolved as Any for every downstream consumer. Verified against installed wheels: strict mypy reported import-untyped and 'Revealed type is "Any"' before, real types after. Both CI and the release job now reject a wheel built without the marker. Two review findings alongside: - factory dispatch returned the literal "schema_0" while the transport's default path used DEFAULT_ADAPTER_KEY. Two sources of truth for the key the two callers of resolve_adapter must agree on, and a divergence is invisible in a dev workspace where every adapter is installed. - required-member derivation screened vars() for callable, which reads as equivalent to "every declared member" and is not: property and classmethod objects are not callable, so a protocol member of either kind would have stopped being required without anyone noticing. The derivation is extracted into a function so the rule is testable against a synthetic protocol rather than asserted in a comment. 432 tests pass, coverage 94%. --- .github/workflows/ci.yml | 16 ++++ .github/workflows/release.yml | 73 +++++++++++++++++-- CHANGELOG.md | 10 +++ packages/schema-0/CHANGELOG.md | 1 + .../src/span_panel_api_schema_0/py.typed | 0 src/span_panel_api/adapters.py | 40 +++++++--- src/span_panel_api/factory.py | 4 +- tests/test_adapters_discovery.py | 48 ++++++++++++ tests/test_factory_dispatch.py | 13 ++++ tests/test_packaging.py | 61 ++++++++++++++++ 10 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 packages/schema-0/src/span_panel_api_schema_0/py.typed create mode 100644 tests/test_packaging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b0983..e8d6c9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,22 @@ jobs: - name: Check packages run: uv run twine check dist/* + # Every distribution here is fully annotated, so every distribution has to + # carry the marker that lets a consumer's type checker see those annotations. + # Without it the package resolves to Any downstream and the typing is inert. + - name: Verify every wheel ships a py.typed marker + run: | + python -c " + import glob, sys, zipfile + wheels = glob.glob('dist/*.whl') + if not wheels: + sys.exit('::error::no wheels were built') + for wheel in wheels: + if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()): + sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any') + print(f'{wheel}: py.typed present') + " + # The configuration entry-point discovery exists to support, and the one # nothing else in CI exercises: the bootstrap wheel installed with no # adapter present. It must import, and it must fail by name rather than diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba05d5f..dd505dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,18 @@ on: release: types: [published] +# This repo publishes two distributions that version independently: the +# bootstrap (span-panel-api) and each schema adapter (span-panel-api-schema-N). +# One release publishes exactly one of them, chosen by the tag prefix: +# +# v3.0.0b1 -> span-panel-api (the historical convention) +# schema-0-v1.0.0b1 -> span-panel-api-schema-0 +# +# The version lives in the distribution's own pyproject.toml and this workflow +# only verifies the tag agrees. It deliberately does not rewrite the version at +# release time: the adapter declares a floor on the bootstrap +# (span-panel-api>=X), so the committed versions are load-bearing for resolution +# and cannot be treated as placeholders that a release stamps over. jobs: deploy: runs-on: ubuntu-latest @@ -25,16 +37,63 @@ jobs: with: enable-cache: true - - name: Update version from tag + - name: Resolve the distribution from the tag + id: target run: | - # Extract version from git tag (remove 'v' prefix if present) - VERSION=${GITHUB_REF#refs/tags/} - VERSION=${VERSION#v} - echo "Setting version to $VERSION" - sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + TAG=${GITHUB_REF#refs/tags/} + case "$TAG" in + schema-*-v*) + SCHEMA=${TAG#schema-} + SCHEMA=${SCHEMA%%-v*} + PACKAGE="span-panel-api-schema-$SCHEMA" + MANIFEST="packages/schema-$SCHEMA/pyproject.toml" + VERSION=${TAG#schema-$SCHEMA-v} + ;; + v*) + PACKAGE="span-panel-api" + MANIFEST="pyproject.toml" + VERSION=${TAG#v} + ;; + *) + echo "::error::Tag '$TAG' names no distribution. Use 'vX.Y.Z' for the bootstrap or 'schema-N-vX.Y.Z' for an adapter." + exit 1 + ;; + esac + if [ ! -f "$MANIFEST" ]; then + echo "::error::Tag '$TAG' resolves to '$MANIFEST', which does not exist." + exit 1 + fi + echo "Tag '$TAG' releases $PACKAGE $VERSION from $MANIFEST" + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "manifest=$MANIFEST" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Verify the tag matches the committed version + run: | + DECLARED=$(python -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['version'])" "${{ steps.target.outputs.manifest }}") + if [ "$DECLARED" != "${{ steps.target.outputs.version }}" ]; then + echo "::error::${{ steps.target.outputs.manifest }} declares version '$DECLARED' but the tag says '${{ steps.target.outputs.version }}'. Commit the version bump before tagging." + exit 1 + fi + echo "Version $DECLARED confirmed." + + # Only the tagged distribution is built, so dist/ holds exactly what this + # release publishes and the publish step cannot pick up a sibling package. - name: Build package - run: uv build + run: uv build --package "${{ steps.target.outputs.package }}" + + - name: Verify the wheel ships a py.typed marker + run: | + python -c " + import glob, sys, zipfile + wheels = glob.glob('dist/*.whl') + if not wheels: + sys.exit('::error::no wheel was built') + for wheel in wheels: + if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()): + sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any') + print(f'{wheel}: py.typed present') + " - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4050bd1..c4df4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,16 @@ than by upgrading the transport. This is prototype work being proven end to end read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major now raises. Previously all three fell through to the flat parser, which does not fail — it produces plausible but wrong power and energy figures. - **Dispatch diagnostics travel through the `SpanMqttClient` constructor**, removing the window where a connected client reported a selected adapter alongside `schema_dispatch_reason='not dispatched'`. +- **Releases are now per-distribution and the tag no longer sets the version.** A tag selects which distribution to publish — `vX.Y.Z` for `span-panel-api`, `schema-N-vX.Y.Z` for an adapter — and the release fails unless the tagged version matches the one + committed in that distribution's `pyproject.toml`. The previous workflow rewrote the root version from the tag and built only the root package, which under a two-distribution layout would have published the bootstrap with no adapter alongside it. Version + numbers are now load-bearing between the two (the adapter declares a floor on the bootstrap), so they belong in the repository rather than being stamped at release time. + +### Fixed + +- **Adapter distributions ship a `py.typed` marker.** Without it a consumer's type checker refuses to read the adapter's annotations and resolves every symbol it exports as `Any`, silently erasing the strict typing at the wheel boundary. CI now fails any + wheel built without one. +- **Protocol conformance checking no longer depends on member kind.** The required-member set is derived from every public member `SchemaAdapter` declares, not only the callable ones — a `property` or `classmethod` object is not callable, so the previous + derivation would have quietly stopped requiring such a member the day the protocol declared one. ## [2.6.4] - 05/2026 diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index e98aa4d..e3ef55e 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -19,6 +19,7 @@ Pre-release. First release as a standalone distribution. per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so a future `schema_1` declares its own rather than inheriting one that does not exist on its firmware. - **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence. +- **A `py.typed` marker**, so consumers type-check against this package's real annotations rather than resolving everything it exports as `Any`. ### Known deviations from the published schema diff --git a/packages/schema-0/src/span_panel_api_schema_0/py.typed b/packages/schema-0/src/span_panel_api_schema_0/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index d4f92e9..2ddfe71 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -17,16 +17,36 @@ _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" _REGISTRY: dict[str, type[SchemaAdapter]] | None = None -# Derived from the protocol rather than restated, so the check cannot drift out -# of sync with the contract it enforces — adding a method to SchemaAdapter -# automatically makes it required of every adapter package. -# -# `issubclass` is not available here: SchemaAdapter has non-method members, and -# runtime_checkable protocols with data attributes reject issubclass() outright. -_REQUIRED_MEMBERS: tuple[str, ...] = ( - *sorted(SchemaAdapter.__annotations__), - *sorted(name for name, value in vars(SchemaAdapter).items() if callable(value) and not name.startswith("_")), -) + +def _derive_required_members(protocol: type) -> tuple[str, ...]: + """Every public member a protocol declares, whatever kind it is. + + Derived from the protocol rather than restated, so the check cannot drift + out of sync with the contract it enforces — adding any public member to + SchemaAdapter automatically makes it required of every adapter package. + + Two sources, because a protocol declares members two ways: annotation-only + data members live in ``__annotations__`` and never reach ``vars()``, while + anything with a body lives in ``vars()`` and is not annotated. + + Member *kind* is deliberately not filtered on. Screening ``vars()`` for + ``callable`` looks equivalent and is not: a ``property`` object is not + callable and neither is a ``classmethod`` object, so that filter would + silently stop requiring a member the day the protocol declared one. Every + public name in ``vars()`` is a member the protocol body declared — Protocol's + own machinery (``_is_protocol``, ``__protocol_attrs__``, ``__subclasshook__``) + is uniformly underscore-prefixed — so no kind check is needed to begin with. + + ``issubclass`` is not an option here: SchemaAdapter has non-method members, + and runtime_checkable protocols with data attributes reject it outright. + """ + return ( + *sorted(getattr(protocol, "__annotations__", {})), + *sorted(name for name in vars(protocol) if not name.startswith("_")), + ) + + +_REQUIRED_MEMBERS: tuple[str, ...] = _derive_required_members(SchemaAdapter) # The adapter key for panels that publish no data-model-version. This is a # bootstrap-level fact — Tier 1 dispatch reads absence as "flat schema" — not an diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 2f7e125..dd066d1 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -9,7 +9,7 @@ import logging import re -from .adapters import resolve_adapter +from .adapters import DEFAULT_ADAPTER_KEY, resolve_adapter from .auth import register_v2 from .detection import detect_api_version from .exceptions import SpanPanelAuthError, SpanPanelSchemaVersionError @@ -51,7 +51,7 @@ def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: extracted from it. """ if data_model_version is None: - return "schema_0", "data-model-version absent (flat schema)" + return DEFAULT_ADAPTER_KEY, "data-model-version absent (flat schema)" if (match := _DMV_CANONICAL.match(data_model_version)) is not None: return f"schema_{int(match.group(1))}", f"data-model-version={data_model_version!r}" diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 0b20f30..80d3a28 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Protocol from unittest.mock import patch import pytest @@ -121,6 +122,53 @@ def test_required_members_are_derived_from_the_protocol() -> None: assert not [member for member in _REQUIRED_MEMBERS if member.startswith("_")] +def test_every_kind_of_declared_member_is_required_not_just_plain_methods() -> None: + """A property is not callable and neither is a classmethod object, so a + kind-filtered derivation would silently stop requiring them. SchemaAdapter + declares only plain methods today; this pins the rule before it declares more.""" + from span_panel_api.adapters import _derive_required_members + + class SurfaceProbe(Protocol): + annotated: str + + @property + def a_property(self) -> int: ... + + @classmethod + def a_classmethod(cls) -> None: ... + + @staticmethod + def a_staticmethod() -> None: ... + + def a_method(self) -> None: ... + + assert set(_derive_required_members(SurfaceProbe)) == { + "annotated", + "a_property", + "a_classmethod", + "a_staticmethod", + "a_method", + } + + +def test_an_adapter_missing_a_non_method_member_is_still_rejected() -> None: + """The end-to-end consequence of the rule above: presence checking has to + reach members that are not plain methods, or a defective adapter registers. + + Built from _REQUIRED_MEMBERS so it stays honest as the protocol grows: the + 'complete' half proves the fixture really does satisfy the check, which is + what makes the 'incomplete' half's rejection attributable to the one + removed member rather than to an unrelated gap. + """ + from span_panel_api.adapters import _REQUIRED_MEMBERS + + complete = {name: (lambda self, *args, **kwargs: None) for name in _REQUIRED_MEMBERS} + incomplete = {name: value for name, value in complete.items() if name != "SUPPORTS_DATA_MODEL_VERSIONS"} + + assert _discover_with(_FakeEntryPoint("schema_9", type("Complete", (), complete))) != {} + assert _discover_with(_FakeEntryPoint("schema_9", type("Incomplete", (), incomplete))) == {} + + @pytest.mark.parametrize( ("label", "value"), [ diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index cc6337f..5326c84 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -62,6 +62,19 @@ def test_absence_is_still_a_supported_signal_not_an_error() -> None: assert key == "schema_0" +def test_the_flat_key_is_the_one_the_transport_resolves() -> None: + """Dispatch and the transport's default path must name the same adapter. + + They are the two callers of resolve_adapter, and a divergence between them + is invisible in a dev workspace where every adapter is installed: it only + appears as an unresolvable key in a real install. + """ + from span_panel_api.adapters import DEFAULT_ADAPTER_KEY + + key, _ = _select_adapter_key(None) + assert key == DEFAULT_ADAPTER_KEY + + def test_missing_adapter_raises_with_the_installed_list() -> None: from span_panel_api.adapters import resolve_adapter diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..4a95325 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,61 @@ +"""Packaging invariants that only bite downstream. + +Nothing in this suite can observe them by importing: a dev workspace resolves +every module from source, where a missing marker file costs nothing. The damage +shows up in someone else's project, against installed wheels, where a fully +annotated distribution silently resolves as Any. +""" + +from __future__ import annotations + +from pathlib import Path +import tomllib + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wheel_source_packages() -> list[tuple[str, Path]]: + """Every importable package each distribution in the workspace ships. + + Read from the manifests rather than listed here, so an adapter added under + packages/ is covered the day it exists rather than the day someone + remembers to extend this file. + """ + manifests = [_REPO_ROOT / "pyproject.toml", *sorted(_REPO_ROOT.glob("packages/*/pyproject.toml"))] + found: list[tuple[str, Path]] = [] + for manifest in manifests: + config = tomllib.loads(manifest.read_text(encoding="utf-8")) + distribution = config["project"]["name"] + for package in config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"]: + # src/ layout only. The root distribution also ships scripts/, which + # is tooling rather than an importable API surface consumers type + # against — see the standing note about it being top-level. + if package.startswith("src/"): + found.append((distribution, manifest.parent / package)) + return found + + +def test_the_workspace_has_more_than_one_distribution() -> None: + """Guards the parametrisation below against passing vacuously: if manifest + discovery breaks, every packaging test silently collects nothing.""" + distributions = {name for name, _ in _wheel_source_packages()} + assert distributions == {"span-panel-api", "span-panel-api-schema-0"} + + +@pytest.mark.parametrize( + ("distribution", "package_dir"), + _wheel_source_packages(), + ids=lambda value: value.name if isinstance(value, Path) else str(value), +) +def test_every_shipped_package_carries_a_py_typed_marker(distribution: str, package_dir: Path) -> None: + """PEP 561: without this file a consumer's type checker refuses to read our + annotations and every symbol we export becomes Any on their side. + + This repo type-checks under --strict and avoids Any deliberately; shipping a + distribution that erases all of that at the wheel boundary undoes the work + for exactly the audience it was done for. + """ + marker = package_dir / "py.typed" + assert marker.is_file(), f"{distribution} ships {package_dir.name} without a py.typed marker" From 413e1e7f94f4ff438cb4208e79b383bd44b8c620 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:31:09 -0700 Subject: [PATCH 2/2] docs(release): note the trusted-publisher prerequisite for a new distribution PyPI trusted publishing is configured per project, so span-panel-api-schema-0 needs a pending publisher created before its first release or the publish step fails on an otherwise correct build. --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd505dd..8d7dcd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,12 @@ on: # release time: the adapter declares a floor on the bootstrap # (span-panel-api>=X), so the committed versions are load-bearing for resolution # and cannot be treated as placeholders that a release stamps over. +# +# Publishing uses PyPI trusted publishing, which is configured per project. A +# distribution released here for the first time needs a pending publisher +# created on PyPI beforehand (project name, this repo, workflow `release.yml`, +# environment `release`); without it the publish step fails on an otherwise +# correct build. jobs: deploy: runs-on: ubuntu-latest