diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 68dc0d2..d729372 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -70,6 +70,28 @@ To install pre-commit hooks: This installs dependencies (if needed) and configures git pre-commit hooks. +## Workspace layout + +This repository is a uv workspace publishing more than one distribution: the bootstrap (`span-panel-api`, at the root) and one parser package per panel schema (`packages/schema-N/`). `uv sync` installs the workspace, so the test suite runs against every +distribution together. + +To work with the packages individually: + +```bash +# Install the workspace including every member +uv sync --all-packages + +# Build every distribution +uv build --all-packages + +# Build just one +uv build --package span-panel-api-schema-0 +``` + +## Releasing + +See [RELEASE.md](RELEASE.md) — each distribution versions and publishes independently, and the tag name selects which one is published. + ## Contributing 1. Fork and clone the repository diff --git a/README.md b/README.md index 2a95b7a..199f321 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,17 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim ## Installation +Two packages: the transport, and a parser for your panel's schema. `span-panel-api` contains no parser — installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. + ```bash -pip install span-panel-api +pip install span-panel-api span-panel-api-schema-0 ``` +`span-panel-api-schema-0` parses the flat schema used by firmware `r202603` through `r202627`, which is every panel in the field today. Panels reporting a `data-model-version` need the adapter for that schema major instead; the error names the one it could +not find and lists what is installed. + +Parsers are discovered through the `span_panel_api.schema_adapters` entry-point group, so support for a new panel schema arrives by installing a package rather than by upgrading the transport. The two version independently — see [RELEASE.md](RELEASE.md). + ### Dependencies - `httpx` — v2 authentication and detection endpoints diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..0d11a33 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,193 @@ +# Releasing + +This repository publishes **more than one PyPI distribution** from a single source tree. That makes releasing less obvious than `git tag && push`, so this document is the reference: what lives where, how a tag selects what gets published, and what an +administrator has to do to release everything. + +## Layout + +One repository, one [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/), independent distributions: + +| Distribution | Directory | Manifest | Purpose | +| ------------------------- | -------------------- | ---------------------------------- | ----------------------------------------------------------------------- | +| `span-panel-api` | repository root | `pyproject.toml` | The **bootstrap** — transport, dispatch, protocols. Contains no parser. | +| `span-panel-api-schema-0` | `packages/schema-0/` | `packages/schema-0/pyproject.toml` | Flat-schema parser (firmware `r202603`–`r202627`) | + +Adapters are discovered at runtime through the `span_panel_api.schema_adapters` entry-point group. The bootstrap never imports an adapter, and adding an adapter to the field is an install, not an upgrade. Future adapters follow the same pattern under +`packages/schema-N/`. + +Consequences for releasing: + +- **Each distribution has its own version number** in its own manifest. +- **Each distribution is its own PyPI project**, with its own trusted publisher. +- **A release publishes exactly one distribution.** Releasing "the repo" means cutting one release per distribution. + +## Two version axes + +The bootstrap and the adapters do not share a version, and this is deliberate rather than an oversight. + +- **The bootstrap** versions on its own library API — the transport and the `SchemaAdapter` protocol. +- **An adapter** versions on _its_ library API. The wire format it parses is fixed and is declared by `SUPPORTS_DATA_MODEL_VERSIONS`, not by the version number. A release of `span-panel-api-schema-0` means the parser changed, never that the panel did. + +So `span-panel-api 3.0.0` and `span-panel-api-schema-0 1.0.0` are unrelated numbers, and either can move without the other. + +Adapters declare a floor on the bootstrap (`span-panel-api>=3.0.0b1,<4.0`). That dependency is why the versions committed in the manifests are load-bearing: they participate in resolution, so they are not placeholders that a release process may overwrite. + +## How a tag selects a distribution + +`.github/workflows/release.yml` runs on `release: published` and derives everything from the tag name. There is no lookup table — the manifest path is computed by convention: + +| Tag | Distribution published | Manifest read | +| ----------------- | ------------------------- | ---------------------------------- | +| `vX.Y.Z` | `span-panel-api` | `pyproject.toml` | +| `schema-N-vX.Y.Z` | `span-panel-api-schema-N` | `packages/schema-N/pyproject.toml` | + +Worked example for `schema-0-v1.0.0b1`: + +```text +TAG = schema-0-v1.0.0b1 +${TAG#schema-} → 0-v1.0.0b1 strip leading "schema-" +${SCHEMA%%-v*} → 0 strip trailing "-v…" ⇒ schema number +PACKAGE = span-panel-api-schema-0 +MANIFEST = packages/schema-0/pyproject.toml +VERSION = ${TAG#schema-0-v} → 1.0.0b1 +``` + +Because the schema number is _extracted_ rather than enumerated, a future `schema-1-v0.1.0` resolves to `packages/schema-1/pyproject.toml` with no change to the workflow. + +A tag matching neither form (`1.2.3`, `nightly`) fails immediately with a message naming both accepted forms. + +## The tag does not set the version + +The workflow **verifies** the version; it does not write it. + +```text +tag schema-0-v1.0.0b1 + ⇒ packages/schema-0/pyproject.toml must declare version = "1.0.0b1" + ⇒ otherwise the job fails without publishing +``` + +This means the release ritual is **bump, commit, then tag** — never tag-and-let-CI-stamp. Earlier versions of this workflow rewrote the version from the tag with `sed`, which cannot work here: there is no single manifest to stamp, and the adapter's +dependency floor on the bootstrap means a stamped version could silently disagree with what resolution actually uses. + +A mismatch is a hard failure with both numbers in the message, so the common mistake — tagging before committing the bump — is caught before anything reaches PyPI. + +## Releasing one distribution + +1. **Bump the version** in that distribution's manifest, and add a `CHANGELOG.md` entry (the root one for the bootstrap, `packages/schema-N/CHANGELOG.md` for an adapter). +2. **Merge to `develop`** (or `main`, once this work is no longer prototype) and let CI go green. +3. **Create a GitHub Release:** + - **Tag** — `vX.Y.Z` or `schema-N-vX.Y.Z`, per the table above. + - **Target** — the branch holding the bump. This defaults to the repository's default branch, which is the easiest thing to get wrong; a tag cut from the wrong branch builds the wrong version and fails the verification step. + - **Set as a pre-release** — tick this for any `aN` / `bN` / `rcN` version. +4. **Watch the run.** `gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')"` + +The job prints exactly what it resolved, which is the first thing to read if something looks wrong: + +```text +Tag 'schema-0-v1.0.0b1' releases span-panel-api-schema-0 1.0.0b1 from packages/schema-0/pyproject.toml +Version 1.0.0b1 confirmed. +``` + +## Releasing every distribution + +There is no "release everything" button, and that is intentional — the distributions version independently, so a coordinated release is a sequence of single-distribution releases rather than one action. + +To release the whole workspace: + +1. Bump every manifest that changed, in one branch, with its changelog entry. +2. If the bootstrap's version moved and adapters need the new floor, update `span-panel-api>=…` in each adapter manifest **in the same branch**. Do not release an adapter whose floor points at a bootstrap version that is not yet on PyPI. +3. Merge and let CI go green. +4. Cut the releases **bootstrap first, then each adapter**: + + ```text + v3.0.0b1 → span-panel-api + schema-0-v1.0.0b1 → span-panel-api-schema-0 + schema-1-v0.1.0 → span-panel-api-schema-1 + ``` + + PyPI accepts them in any order, but bootstrap-first means there is never a window in which an adapter is installable and its dependency is not. + +5. Verify from PyPI rather than from CI — see below. + +Only bump and release what actually changed. A distribution with no changes does not need a release just because a sibling had one. + +## Adding a new adapter + +When `packages/schema-N/` lands, the workflow needs no edit — but PyPI does, and this is the step that will be forgotten: + +1. **Create the PyPI project and its trusted publisher before the first release.** Because the project does not exist yet, this is a _pending publisher_, added from account/organization publishing settings rather than from the (non-existent) project page: + + | Field | Value | + | ----------------- | ------------------------- | + | PyPI Project Name | `span-panel-api-schema-N` | + | Owner | `SpanPanel` | + | Repository name | `span-panel-api` | + | Workflow name | `release.yml` | + | Environment name | `release` | + + Every field except the project name is identical across all distributions here, since they all publish from the same repository and workflow. Once the project exists, the same entry is visible and editable at + `https://pypi.org/manage/project//settings/publishing/`. + +2. **Add the package to the workspace** — it is matched by `members = ["packages/*"]` automatically, but the root `[tool.uv.sources]` and the dev dependency group need an entry if the test suite is to exercise it. +3. **Ship a `py.typed` marker** in the new package. CI fails the build without it. + +Trusted publishing verifies repository, workflow filename, and environment — it cannot distinguish _which_ distribution a run is building. That is inherent to a monorepo, and it is why the workflow builds only the tagged package: `dist/` never contains a +sibling that could be uploaded by accident. + +## What the workflow checks + +In order, all before anything is uploaded: + +1. **Tag names a known distribution** — otherwise fail, naming both accepted forms. +2. **The derived manifest exists** — catches a `schema-N` tag with no matching directory. +3. **The tag version equals the committed version** — read with `tomllib`, compared exactly. +4. **Only the tagged package is built** — `uv build --package `, so `dist/` holds exactly one distribution. +5. **Every built wheel ships `py.typed`** — a fully annotated distribution that omits it resolves as `Any` for every downstream consumer, silently undoing the strict typing this repository maintains. + +| Failure | Meaning | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `Tag '…' names no distribution` | Tag is malformed. Use `vX.Y.Z` or `schema-N-vX.Y.Z`. | +| `resolves to '…', which does not exist` | Tag names a schema whose directory is not in this commit — usually a tag cut from the wrong branch. | +| `declares version 'A' but the tag says 'B'` | The bump was not committed, or the release targets the wrong branch. | +| `ships no py.typed marker` | The new package is missing the marker file. | +| OIDC / trusted publishing rejection | The PyPI publisher for that project is missing or does not match. Nothing was uploaded; fix and re-run the job. | + +A failed release is safe. Every check runs before upload, so a failure means nothing reached PyPI and the same tag can be re-run once the cause is fixed. + +## Verifying a release + +CI going green proves the build, not the install. The seam this repository is built around — a bootstrap that finds a parser it never imports — can only be exercised across a real package boundary, so verify from PyPI: + +```bash +# 1. The bootstrap alone must fail by name, not with ModuleNotFoundError +python3 -m venv .solo && ./.solo/bin/pip install --pre span-panel-api +./.solo/bin/python -c " +from span_panel_api.adapters import discover_adapters, resolve_adapter, DEFAULT_ADAPTER_KEY +from span_panel_api.exceptions import SpanPanelAdapterMissingError +print('adapters:', sorted(discover_adapters())) +try: + resolve_adapter(DEFAULT_ADAPTER_KEY, 'release check') +except SpanPanelAdapterMissingError as exc: + print('raised as designed:', exc.needed, exc.available) +" + +# 2. Both packages: the adapter resolves through discovery +python3 -m venv .both && ./.both/bin/pip install --pre span-panel-api span-panel-api-schema-0 +./.both/bin/python -c " +from span_panel_api.adapters import discover_adapters +print('adapters:', sorted(discover_adapters())) +" +``` + +Expected: `adapters: []` then a named `SpanPanelAdapterMissingError` in the first, `adapters: ['schema_0']` in the second. + +Drop `--pre` once the versions being verified are not pre-releases. + +## Pre-releases + +Versions like `3.0.0b1` are pre-releases in both places that matter: + +- **PyPI** will not install them without `--pre`, so `pip install span-panel-api` continues to resolve the last stable release. +- **GitHub** should have "Set as a pre-release" ticked, which keeps them out of the repository's "Latest release" slot. + +The publish workflow itself does not care — `on: release: published` fires either way.