Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 72 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/schema-0/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Empty file.
40 changes: 30 additions & 10 deletions src/span_panel_api/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/span_panel_api/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down
48 changes: 48 additions & 0 deletions tests/test_adapters_discovery.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from typing import Protocol
from unittest.mock import patch

import pytest
Expand Down Expand Up @@ -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"),
[
Expand Down
13 changes: 13 additions & 0 deletions tests/test_factory_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 61 additions & 0 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
@@ -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"