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..8d7dcd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,24 @@ 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. +# +# 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 @@ -25,16 +43,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"