From 48b64a4f618c42809615ab018cbe032387bc72f2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:28:31 -0700 Subject: [PATCH] refactor: reference payloads become test fixtures, not package data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reference_payloads` sat inside `src/span_panel_api/` and `packages/schema-1/src/span_panel_api_schema_1/`, so both wheels carried it — not through any packaging declaration, but because a directory inside a package directory ships. No runtime path read either capture, so every install of both distributions paid for 56 KB of test data it could not use, and the import surface committed each distribution to a promise it never meant to make. It shipped that way for a real reason: a vendored copy has no version, goes stale in silence, and a consumer's conformance gate then verifies its declarations against a schema no panel runs. The reason does not survive inspection. Detecting staleness never needed the file, only the version claim, and `importlib.metadata.version(...)` gives any consumer that without a checkout. A vendored copy that records the release it was taken from and asserts it against the installed distribution gets the loud failure the package data was protecting. Both captures move to `tests/reference_payloads/`, split the way the distributions were: `bootstrap` holds the homie schema document and imports nothing an adapter-less environment lacks, `schema_one` holds the retained-topic tree and the replay that reads it, which reaches the eBus SDK. Keeping the split means a test that wants the schema document does not drag the SDK in behind it. `devices_from_tree` / `device_from_topics` stay beside the capture for the reason they were written: separating them would put the same twelve lines of replay in each of the modules that read it. Imported as a top-level package, the same arrangement that makes `from conftest import ...` work throughout this suite. Two guards, because nothing declares what ships and so nothing would object to this recurring. `tests/test_packaging.py` fails if a capture reappears inside any shipped package, derived from the manifests rather than a hardcoded list; CI fails if any built wheel carries one, which is where it is finally true rather than inferred. A third resolves `spec_lock.json`'s recorded capture path, which nothing read before — the path went stale in this very change with nothing objecting. The mypy hook now checks `tests/reference_payloads/` alone out of `tests/`. Its five accessors were type-checked under --strict as package data and are read by twenty test modules; moving them should not quietly cost them that. Closes the library half of #162. --- .github/workflows/ci.yml | 25 +++++++ .github/workflows/peer-drift.yml | 2 +- .pre-commit-config.yaml | 8 ++- CHANGELOG.md | 11 ++++ DEVELOPMENT.md | 12 ++-- README.md | 26 ++------ packages/schema-1/CHANGELOG.md | 10 +++ packages/schema-1/README.md | 14 +--- .../span_panel_api_schema_1/spec_lock.json | 2 +- scripts/capture_parent_child_reference.py | 19 +++--- scripts/reference_panel.yaml | 2 +- scripts/verify_reconnect.py | 2 +- .../reference_payloads/README.md | 23 ------- .../reference_payloads/__init__.py | 66 ------------------- tests/fixtures/v2/README.md | 4 +- .../reference_payloads/README.md | 32 ++++++++- tests/reference_payloads/__init__.py | 29 ++++++++ tests/reference_payloads/bootstrap.py | 48 ++++++++++++++ .../reference_payloads/homie_schema.json | 0 .../reference_payloads/parent_child_tree.json | 0 .../reference_payloads/schema_one.py | 45 ++++++------- tests/test_adoption.py | 2 +- tests/test_catalog_divergence.py | 4 +- tests/test_detection_auth.py | 2 +- tests/test_packaging.py | 44 +++++++++++++ tests/test_reference_tree_values.py | 5 +- tests/test_schema_one_adapter.py | 2 +- tests/test_schema_one_against_simulator.py | 4 +- tests/test_schema_one_charge_limit.py | 2 +- tests/test_schema_one_circuits.py | 4 +- tests/test_schema_one_conformance.py | 21 +++++- tests/test_schema_one_connection_health.py | 10 +-- tests/test_schema_one_control_refusal.py | 6 +- tests/test_schema_one_devices.py | 2 +- tests/test_schema_one_discovery.py | 10 +-- tests/test_schema_one_extension.py | 2 +- tests/test_schema_one_panel.py | 4 +- tests/test_schema_one_pcs.py | 8 +-- tests/test_schema_one_service_entrance.py | 10 +-- tests/test_schema_one_shed_forecast.py | 6 +- tests/test_schema_one_snapshot.py | 2 +- tests/test_schema_provenance.py | 2 +- 42 files changed, 317 insertions(+), 215 deletions(-) delete mode 100644 src/span_panel_api/reference_payloads/README.md delete mode 100644 src/span_panel_api/reference_payloads/__init__.py rename {packages/schema-1/src/span_panel_api_schema_1 => tests}/reference_payloads/README.md (59%) create mode 100644 tests/reference_payloads/__init__.py create mode 100644 tests/reference_payloads/bootstrap.py rename {src/span_panel_api => tests}/reference_payloads/homie_schema.json (100%) rename {packages/schema-1/src/span_panel_api_schema_1 => tests}/reference_payloads/parent_child_tree.json (100%) rename packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py => tests/reference_payloads/schema_one.py (56%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3728aee..4f530d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,31 @@ jobs: print(f'{wheel}: py.typed present') " + # Reference captures are test data. They shipped in both wheels until 3.1.0 + # -- not through any packaging declaration, but because a directory inside a + # package directory ships -- and no runtime path ever read them. Nothing in + # the manifests would object to that happening again, so the built artifact + # is where it has to be asserted. Every wheel, not the two known ones: an + # adapter added under packages/ is covered the day it exists. + - name: Verify no wheel ships reference payloads + run: | + python -c " + import glob, posixpath, sys, zipfile + wheels = glob.glob('dist/*.whl') + if not wheels: + sys.exit('::error::no wheels were built') + for wheel in wheels: + names = zipfile.ZipFile(wheel).namelist() + carried = [ + n for n in names + if 'reference_payloads' in n.split('/') + or posixpath.basename(n) in ('homie_schema.json', 'parent_child_tree.json') + ] + if carried: + sys.exit(f'::error::{wheel} ships test data: {carried}. Reference captures are fixtures under tests/reference_payloads; a directory inside a package directory ships whether or not the manifest names it.') + print(f'{wheel}: no reference payloads') + " + # 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/peer-drift.yml b/.github/workflows/peer-drift.yml index 26d14ea..296c2d1 100644 --- a/.github/workflows/peer-drift.yml +++ b/.github/workflows/peer-drift.yml @@ -193,7 +193,7 @@ jobs: print("```bash") print(f"# in a checkout of the emitter at v{latest}, from its own environment") print("uv run python ../span-panel-api/scripts/capture_parent_child_reference.py \\") - print(" ../span-panel-api/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json") + print(" ../span-panel-api/tests/reference_payloads/parent_child_tree.json") print("```\n") print("The script refuses until `peers.ebus-panel-sim.version` and `.commit` in") print("`spec_lock.json` name the release you captured from, which is what keeps the") diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc6c95c..7c8e6b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -87,7 +87,13 @@ repos: # types rather than silently reporting import-not-found. - ebus-sdk>=0.19.0 args: ['--config-file=pyproject.toml'] - exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' + # `tests/reference_payloads/` is the one thing under tests/ this hook does + # check. Its five accessors were type-checked as package data until 3.1.0 + # and are read by twenty test modules; moving them out of `src/` should not + # have quietly cost them `--strict`. `pyproject.toml`'s own `exclude` still + # names tests/, which is fine — that flag governs directory discovery, and + # a file passed by name is checked regardless. + exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/(?!reference_payloads/).*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' # Pylint for code quality - repo: https://github.com/pycqa/pylint diff --git a/CHANGELOG.md b/CHANGELOG.md index 289b998..7cdabb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,17 @@ install of the adapter distribution does not, and a 1.0.0 adapter against this b would have meant a caller passing both got system trust while believing it had pinned the panel CA — a security control that appears to be on and is off. When both are supplied, a dedicated client is built for the call and closed after it. The cost is named rather than hidden: those calls lose the injected client's connection pool, timeout and header policy. Acceptable because every caller here is bootstrap — registration, detection, schema, FQDN, status — a handful of calls per config entry. +### Removed + +- **BREAKING: `span_panel_api.reference_payloads` is gone, and the wheel no longer carries `homie_schema.json`.** The captured `GET /api/v2/homie/schema` response is a fixture of this repository's test suite now, at + `tests/reference_payloads/homie_schema.json`, read through `homie_schema()` / `homie_schema_types()` there. Anyone importing the module from an installed distribution has to vendor the bytes instead — and should record which release they were taken from, + asserting that against `importlib.metadata.version("span-panel-api")`, so a pin that moves past a stale copy fails loudly rather than checking declarations against a schema no panel runs. That version claim is available to any consumer without a + checkout, which is what makes vendoring safe and is the whole reason this can be removed. + + It shipped in the first place to spare consumers a copy that goes stale in silence, which was a real problem badly solved: no runtime path ever read the file, so every install of both distributions paid for test data it could not use, and the import + surface committed each distribution to a promise it never meant to make. Nothing declared the payloads as package data — a directory inside a package directory ships whether or not a manifest names it, which is exactly why this was easy to miss. + `tests/test_packaging.py` now fails if a capture reappears inside a shipped package, and CI asserts the same against every built wheel. See #162. + ## [3.0.1] ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f6a381c..9f63545 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -78,17 +78,17 @@ now records the simulator commit its output came from, for the same reason `spec Two scripts, one per producer, and neither is run automatically — a capture is a deliberate act. -| Artifact | Producer | Script | -| ----------------------------------------------------------------- | ---------------- | ------------------------------------------- | -| `tests/fixtures/flat_wire.json` | `simulator` | `scripts/capture_flat_reference.py` | -| `packages/schema-1/.../reference_payloads/parent_child_tree.json` | `ebus-panel-sim` | `scripts/capture_parent_child_reference.py` | +| Artifact | Producer | Script | +| ------------------------------------------------- | ---------------- | ------------------------------------------- | +| `tests/fixtures/flat_wire.json` | `simulator` | `scripts/capture_flat_reference.py` | +| `tests/reference_payloads/parent_child_tree.json` | `ebus-panel-sim` | `scripts/capture_parent_child_reference.py` | Both run from the **producer's** environment rather than this one — each producer caps a dependency this repo installs above — and both substitute the transport rather than reassembling the emitter, because a capture taken through different wiring than a real panel uses is a capture of the wiring. Point them at a checkout with `SIMULATOR_DIR` / `PANEL_SIM_DIR`. `capture_parent_child_reference.py` goes one step further than documenting its producer: it reads the release it is a capture of out of `spec_lock.json` (`peers.ebus-panel-sim.version`) and **refuses to write** when the installed package disagrees. The pin -therefore has exactly one home, and re-capturing against a newer emitter is a two-place change made together — that peer block, and the provenance section of `reference_payloads/README.md`. That is what stops the bytes and the claim about them drifting -apart, and the drift is not hypothetical: it is how a producer defect in `$settable` on a locked relay reached about thirty test files across two repositories with no conformance gate objecting. +therefore has exactly one home, and re-capturing against a newer emitter is a two-place change made together — that peer block, and the provenance section of `tests/reference_payloads/README.md`. That is what stops the bytes and the claim about them +drifting apart, and the drift is not hypothetical: it is how a producer defect in `$settable` on a locked relay reached about thirty test files across two repositories with no conformance gate objecting. Its input is committed too, as `scripts/reference_panel.yaml`, pinned as `peers.ebus-panel-sim.manifest` — a capture whose input is not in the tree is the same class of problem as one whose producer is not recorded. That manifest is a synthetic `example-*` panel that mirrors the emitter's own `examples/forty_tab_minimal.yaml` key for key and marks its two deliberate divergences at the head of the file: spec-legal shed priorities in place of a value the emitter degrades to `UNKNOWN` diff --git a/README.md b/README.md index ae1e688..7c02b5c 100644 --- a/README.md +++ b/README.md @@ -527,26 +527,11 @@ The `PanelCapability` flag enum advertises transport features at runtime: ## Reference Payloads -Captures of what a panel actually serves, shipped as package data so a consumer can check its own assumptions against real bytes without vendoring a copy that silently goes stale: +Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — live in [`tests/reference_payloads/`](tests/reference_payloads/README.md), with their provenance. -```python -from span_panel_api.reference_payloads import homie_schema, homie_schema_types - -document = homie_schema() # the captured GET /api/v2/homie/schema response -types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes -``` - -`homie_schema_types()` returns exactly what `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so building real adapter metadata to compare against is two lines and no file handling. - -The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it: - -```python -from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree - -devices = devices_from_tree(parent_child_tree()) -``` - -Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against. +**They are fixtures of this repository, not package data.** Until 3.1.0 they sat inside the two source packages and were therefore carried in the wheels, which no runtime path ever read. `span_panel_api.reference_payloads` and +`span_panel_api_schema_1.reference_payloads` no longer exist; a consumer that was importing them should vendor the bytes it needs and record the release it took them from, asserting that against `importlib.metadata.version(...)` so a moved pin that outruns +the copy fails loudly instead of testing against a schema no panel runs. ## Project Structure @@ -567,7 +552,6 @@ src/span_panel_api/ # distribution: span-panel-api (no parser) ├── phase_validation.py # Electrical phase utilities ├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags ├── schema_drift.py # Reporting a panel that outruns what we can read -├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data └── mqtt/ ├── __init__.py ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern) @@ -585,7 +569,7 @@ packages/schema-1/ # distribution: span-panel-api-schema-1 ├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed └── src/span_panel_api_schema_1/ # Parent/child parser: ControllerRoutes, snapshot mapper, - # adoption, catalog validator, spec_lock.json, reference payloads + # adoption, catalog validator, spec_lock.json ``` ## Development diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 30b2731..27bd1c2 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -84,6 +84,16 @@ Requires `span-panel-api` **3.1.0 or newer**, and the two must be upgraded toget handling them is a contract obligation regardless of what hardware emits — and `test_every_catalogued_priority_is_carried_through_rather_than_repaired` is now parametrised over the catalog's declared format rather than over whatever the capture happens to contain. Contract obligations come from the catalog; representativeness comes from the capture. A value added upstream reaches that test the moment the catalog is re-vendored. +### Removed + +- **BREAKING: `span_panel_api_schema_1.reference_payloads` is gone, and the wheel no longer carries `parent_child_tree.json`.** The 40-space retained-topic capture, and the `parent_child_tree()` / `device_from_topics()` / `devices_from_tree()` replay that + reads it, are fixtures of the repository's test suite now, at `tests/reference_payloads/schema_one.py`. The helpers stay beside the capture wherever it lives, for the reason they were written: a tree is not directly usable, and separating them would put + the same twelve lines of replay in each of the modules that read it. + + It was package data for a good reason — a vendored copy has no version and goes stale in silence — but the reason does not survive inspection: no runtime path here reads the capture, so every install paid 40 KB for test data, and detecting staleness + never needed the file, only the version claim. A consumer that vendors the bytes and asserts its recorded source release against `importlib.metadata.version("span-panel-api-schema-1")` gets the loud failure the package data was protecting, with no + checkout and no dependency on this distribution's test fixtures. `spec_lock.json`'s `peers.ebus-panel-sim.produces.tree` names the new location, and a test resolves it so the record cannot outlive the file. See #162. + ## [1.0.0] First release as a standalone distribution, and the first parser for the parent/child data model. Requires `span-panel-api` 3.0.0 or newer, and `ebus-sdk` `>=0.19,<0.24`. diff --git a/packages/schema-1/README.md b/packages/schema-1/README.md index 10a94b8..2441d48 100644 --- a/packages/schema-1/README.md +++ b/packages/schema-1/README.md @@ -50,15 +50,7 @@ returns `None`, and an entity disappears. ## Reference payloads -A retained-topic capture of a full 40-space parent/child panel ships as package data, with the replay that turns it back into devices: +A retained-topic capture of a full 40-space parent/child panel, and the replay that turns it back into devices, are fixtures of the repository's test suite at `tests/reference_payloads/`. -```python -from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree - -devices = devices_from_tree(parent_child_tree()) -``` - -It ships here rather than from the bootstrap because a retained topic tree is only interpretable by the parser that speaks its vocabulary, and the eBus SDK is this distribution's dependency alone. `devices_from_tree` takes the tree rather than reading it, -so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still build devices the same way. The bootstrap ships the schema document it fetches; see `span_panel_api.reference_payloads`. - -Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against. +**`span_panel_api_schema_1.reference_payloads` no longer exists.** It was package data until 1.1.0 — carried in this wheel because it sat inside the package directory, though no runtime path read it. A consumer that was importing it should vendor the bytes +it needs and record the release it took them from, asserting that against `importlib.metadata.version("span-panel-api-schema-1")` so a moved pin that outruns the copy fails loudly instead of testing against a tree no panel publishes. diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 95416e6..a46d775 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -35,7 +35,7 @@ "capture_script": "scripts/capture_parent_child_reference.py", "manifest": "scripts/reference_panel.yaml", "produces": { - "tree": "packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json" + "tree": "tests/reference_payloads/parent_child_tree.json" } } }, diff --git a/scripts/capture_parent_child_reference.py b/scripts/capture_parent_child_reference.py index 82b1773..8d6d7a5 100644 --- a/scripts/capture_parent_child_reference.py +++ b/scripts/capture_parent_child_reference.py @@ -1,16 +1,17 @@ """Capture the parent/child emitter's retained surface, without a broker. -Produces `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/ -parent_child_tree.json`, the schema_1 reference tree that ships as package data -and that fifteen test modules here replay through `devices_from_tree`. +Produces `tests/reference_payloads/parent_child_tree.json`, the schema_1 +reference tree that fifteen test modules here replay through `devices_from_tree`. +A repository fixture, not package data: it sat inside the adapter's package +directory until 3.1.0 and was carried in the wheel for it, and no consumer of +either distribution reads it at runtime. Run it from the **emitter's** environment, not this one — it imports `ebus_panel_sim`, which caps `ebus-sdk` below the version this repo installs: cd ../distribution-enclosure-simulator uv run python ../span-panel-api/scripts/capture_parent_child_reference.py \\ - ../span-panel-api/packages/schema-1/src/span_panel_api_schema_1/\\ -reference_payloads/parent_child_tree.json + ../span-panel-api/tests/reference_payloads/parent_child_tree.json `PANEL_SIM_DIR` overrides where the checkout is looked for; it defaults to a `distribution-enclosure-simulator` directory beside this repo. Passing no output @@ -475,11 +476,11 @@ def main() -> None: if PRODUCER_VERSION != expected: raise SystemExit( f"{SIM} is ebus-panel-sim {PRODUCER_VERSION}, and spec_lock.json records the reference " - f"tree as a capture of {expected}. Capturing anyway would put bytes in the wheel that " - "the lockfile attributes to a release that did not make them. Move the checkout to the " - "pinned release, or take the new capture deliberately: update peers.ebus-panel-sim's " + f"tree as a capture of {expected}. Capturing anyway would put bytes in this repository " + "that the lockfile attributes to a release that did not make them. Move the checkout to " + "the pinned release, or take the new capture deliberately: update peers.ebus-panel-sim's " "version, tag and commit in spec_lock.json, and the provenance section of " - "reference_payloads/README.md, in the same change." + "tests/reference_payloads/README.md, in the same change." ) profile = Profile(MANIFEST) diff --git a/scripts/reference_panel.yaml b/scripts/reference_panel.yaml index 73a8311..06fafe7 100644 --- a/scripts/reference_panel.yaml +++ b/scripts/reference_panel.yaml @@ -2,7 +2,7 @@ # # Read by `scripts/capture_parent_child_reference.py`, which drives the eBus # emitter (`ebus-panel-sim`) with it and records the retained topics as -# `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json`. +# `tests/reference_payloads/parent_child_tree.json`. # Recorded in `spec_lock.json` as `peers.ebus-panel-sim.manifest`, so the capture's # input is pinned the same way its producer is: a capture whose input is not in the # tree is the same class of problem as one whose producer version is not written down. diff --git a/scripts/verify_reconnect.py b/scripts/verify_reconnect.py index ef9c523..c28128e 100644 --- a/scripts/verify_reconnect.py +++ b/scripts/verify_reconnect.py @@ -39,7 +39,7 @@ --broker-host 127.0.0.1 --broker-port 1883 --no-tls \ --data-model-version 1.0 \ --adapter span_panel_api_schema_1:SchemaOneAdapter \ - --seed packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json + --seed tests/reference_payloads/parent_child_tree.json Exits non-zero if any check fails. """ diff --git a/src/span_panel_api/reference_payloads/README.md b/src/span_panel_api/reference_payloads/README.md deleted file mode 100644 index 80d5fd1..0000000 --- a/src/span_panel_api/reference_payloads/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Reference payloads - -Shipped as package data and read through `span_panel_api.reference_payloads`, never by path — a consumer that installs this distribution gets these bytes, and a consumer that pins a version gets the bytes that version was written against. - -## `homie_schema.json` - -The `GET /api/v2/homie/schema` response, captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Unauthenticated endpoint. Serial numbers are masked (last 4 chars replaced with `XXXX`). - -Schema hash `sha256:d347556a07d98f40` — compare against `typesSchemaHash` in a live response to detect a schema change across firmware versions. `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to this value, and `tests/test_schema_provenance.py` -fails when the two diverge. - -### Node types present - -| Node Type | Properties | Notes | -| ------------------------------------------------ | ---------- | -------------------------------------------------- | -| `energy.ebus.device.distribution-enclosure.core` | 17 | Panel-wide state, network, hardware | -| `energy.ebus.device.lugs` | 7 | Upstream (main meter) and downstream (feedthrough) | -| `energy.ebus.device.circuit` | 16 | Per-circuit — one node per commissioned circuit | -| `energy.ebus.device.bess` | 12 | Battery — optional, only if commissioned | -| `energy.ebus.device.pv` | 7 | Solar — optional, only if commissioned | -| `energy.ebus.device.evse` | 9 | EV charger — optional, only if commissioned | -| `energy.ebus.device.pcs` | 15 | Power Control System — optional | -| `energy.ebus.device.power-flows` | 4 | Aggregated power flows (W) | diff --git a/src/span_panel_api/reference_payloads/__init__.py b/src/span_panel_api/reference_payloads/__init__.py deleted file mode 100644 index a8596f9..0000000 --- a/src/span_panel_api/reference_payloads/__init__.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Reference wire payloads, shipped as package data. - -These are captures of what a panel actually serves, published as part of the -distribution rather than kept in `tests/` — because the consumers that need -them most are *other* repositories. The Home Assistant integration checks the -field paths it declares against what the adapters can actually produce, and it -can only do that against a real schema document. Vendoring a copy of one is the -obvious move and the wrong one: a copy has no version, so it goes stale in -silence and the check starts verifying declarations against a schema no panel -runs. - -Shipping the payload here gives it the version of the release it came with. A -consumer that pins `span-panel-api==X` reads the document that release was -written against, by construction, with no copy to keep in sync. - -`homie_schema.json` belongs to this distribution and not to an adapter one: it -is the response of `span_panel_api.auth.get_homie_schema()`, modelled by -`V2HomieSchema` here, and dispatch reads its `data_model_version` to decide -*which* adapter parses the panel at all. The parent/child device tree is the -other half of that story and lives with the parser that can interpret it, in -`span_panel_api_schema_1.reference_payloads`. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from importlib import resources -import json - -from span_panel_api.models import HomieSchemaTypes - -_PACKAGE = "span_panel_api.reference_payloads" -_HOMIE_SCHEMA = "homie_schema.json" - - -def _load_object(name: str) -> Mapping[str, object]: - """Read one shipped payload and require it to be a JSON object.""" - text = resources.files(_PACKAGE).joinpath(name).read_text(encoding="utf-8") - document: object = json.loads(text) - if not isinstance(document, dict): - raise TypeError(f"{name} is not a JSON object") - return document - - -def homie_schema() -> Mapping[str, object]: - """The captured `GET /api/v2/homie/schema` response, parsed. - - Taken from a live 32-space panel on `spanos2/r202603/05`; serial numbers are - masked. `typesSchemaHash` is `sha256:d347556a07d98f40`, which is the value - `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to. - """ - return _load_object(_HOMIE_SCHEMA) - - -def homie_schema_types() -> HomieSchemaTypes: - """The captured schema's `types` map. - - Separate from `homie_schema()` because this is the shape a field-metadata - build takes — `span_panel_api_schema_0.field_metadata.build_field_metadata` - accepts exactly this type — so a caller checking an adapter's output against - the schema never has to reach into an untyped document to get it. - """ - types = homie_schema()["types"] - if not isinstance(types, dict): - raise TypeError(f"{_HOMIE_SCHEMA} has no `types` object") - return types diff --git a/tests/fixtures/v2/README.md b/tests/fixtures/v2/README.md index bfeabd1..20a2c41 100644 --- a/tests/fixtures/v2/README.md +++ b/tests/fixtures/v2/README.md @@ -10,5 +10,5 @@ Captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Serial nu ## Moved -`homie_schema.json` is no longer a test fixture. It ships as package data at `src/span_panel_api/reference_payloads/homie_schema.json` and is read through `span_panel_api.reference_payloads.homie_schema()` — by this suite and by consumers alike, so there -is no copy anywhere that can go stale. Its provenance, schema hash and node-type table live in the README next to it. +`homie_schema.json` lives at [`tests/reference_payloads/homie_schema.json`](../../reference_payloads/README.md) and is read through `reference_payloads.bootstrap.homie_schema()`. It was package data under `src/span_panel_api/` between 3.0.0 and 3.1.0; +nothing at runtime read it there, so it is an ordinary fixture again. Its provenance, schema hash and node-type table live in the README next to it. diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md b/tests/reference_payloads/README.md similarity index 59% rename from packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md rename to tests/reference_payloads/README.md index add042d..33efe0f 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md +++ b/tests/reference_payloads/README.md @@ -1,13 +1,39 @@ # Reference payloads -Shipped as package data and read through `span_panel_api_schema_1.reference_payloads`, never by path — a consumer that installs this distribution gets these bytes, and a consumer that pins a version gets the bytes that version's parser was written against. +Captures of what a panel actually serves, read by this repository's test suite through `reference_payloads.bootstrap` and `reference_payloads.schema_one`. + +**These are repository fixtures, not package data.** Until 3.1.0 they sat inside `src/span_panel_api/` and `packages/schema-1/src/span_panel_api_schema_1/`, so both wheels carried them — not through any packaging declaration, but because a directory inside +a package directory ships. Nothing at runtime read them, and nothing does now. `tests/test_packaging.py` fails if a payload directory reappears inside a shipped package, and CI asserts the same against the built wheels. + +## `homie_schema.json` + +The `GET /api/v2/homie/schema` response, captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Unauthenticated endpoint. Serial numbers are masked (last 4 chars replaced with `XXXX`). + +Schema hash `sha256:d347556a07d98f40` — compare against `typesSchemaHash` in a live response to detect a schema change across firmware versions. `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to this value, and `tests/test_schema_provenance.py` +fails when the two diverge. + +### Node types present + +| Node Type | Properties | Notes | +| ------------------------------------------------ | ---------- | -------------------------------------------------- | +| `energy.ebus.device.distribution-enclosure.core` | 17 | Panel-wide state, network, hardware | +| `energy.ebus.device.lugs` | 7 | Upstream (main meter) and downstream (feedthrough) | +| `energy.ebus.device.circuit` | 16 | Per-circuit — one node per commissioned circuit | +| `energy.ebus.device.bess` | 12 | Battery — optional, only if commissioned | +| `energy.ebus.device.pv` | 7 | Solar — optional, only if commissioned | +| `energy.ebus.device.evse` | 9 | EV charger — optional, only if commissioned | +| `energy.ebus.device.pcs` | 15 | Power Control System — optional | +| `energy.ebus.device.power-flows` | 4 | Aggregated power flows (W) | ## `parent_child_tree.json` Retained topics captured off the eBus emitter's parent/child tree for a 40-space panel: 13 devices — the panel, both lugs, a BESS with its MID, a PV, an EVSE, and the circuits. Shape is `{device_id: {topic: payload}}`, every value a string, exactly as the broker retains them. `$description` is therefore a **JSON string**, not a nested object; `device_from_topics` replays it the way the transport does. `bess-mid` is typed -`energy.ebus.device.mid`, not `.bess` — a consumer filtering the tree by type marker has to expect the MID to survive a BESS filter. +`energy.ebus.device.mid`, not `.bess` — a reader filtering the tree by type marker has to expect the MID to survive a BESS filter. + +`devices_from_tree` and `device_from_topics` live in `schema_one.py` beside the capture rather than in whichever test first needed them: a tree is not directly usable, every reader of it has to replay the retained topics through `DiscoveredDevice` first, +and splitting the two would put the same twelve lines in each of the modules that read it. ### Provenance @@ -34,7 +60,7 @@ producer is not written down. It mirrors `examples/forty_tab_minimal.yaml` key f production enclosures we hold captures from — 27 circuits — no panel has ever published `UNKNOWN`, so this manifest uses values a real panel publishes. **`UNKNOWN` is still a legal enum member and this parser must handle it**; that obligation comes from `load-shed` 0.3's declared `$format` and is tested from the catalog in `tests/test_schema_one_circuits.py`, not from this capture. Contract obligations come from the catalog; representativeness comes from the capture. 2. **Identity properties.** The BESS's part/serial/firmware, the MID's model, firmware and hardware version, and the PV's firmware are all published by real panels and unset in the example. A capture omitting them understates what a consumer has to parse — - which is what left four library tests injecting those values by hand, reading as coverage while asking nothing about what a panel sends. Every value is synthetic (`example-40t-001`, `EXAMPLE-BESS-40T-001`), because these bytes ship in a wheel. + which is what left four library tests injecting those values by hand, reading as coverage while asking nothing about what a panel sends. Every value is synthetic (`example-40t-001`, `EXAMPLE-BESS-40T-001`). The cost of that choice is real: the capture is no longer reproducible by running an example anyone can find in the emitter. The committed manifest is what buys it back. diff --git a/tests/reference_payloads/__init__.py b/tests/reference_payloads/__init__.py new file mode 100644 index 0000000..2f70784 --- /dev/null +++ b/tests/reference_payloads/__init__.py @@ -0,0 +1,29 @@ +"""Reference wire payloads, as ordinary fixtures of this repository's suite. + +Captures of what a panel actually serves. They lived inside the two source +packages until 3.1.0 and were therefore carried in the wheels — not through any +packaging declaration, but simply because a directory inside `src//` +ships. Nothing at runtime ever read them, so every install paid for test data it +could not use, and a consumer who imported them acquired a dependency on files +the distributions never meant to promise. They are here now, where their only +readers are. + +Two modules rather than one, split the way the distributions are: + +- `bootstrap` — `homie_schema.json`, the document `span_panel_api.auth` fetches + and dispatch reads `data_model_version` out of. No adapter is involved, so + this module imports nothing an adapter-less environment lacks. +- `schema_one` — `parent_child_tree.json`, a retained-topic capture, which is + only interpretable by the parser that speaks its vocabulary. Importing it + reaches the eBus SDK, which is `span-panel-api-schema-1`'s dependency alone. + +Keeping the split means a test that needs the schema document never drags the +SDK in behind it, which is the same reason the two payloads were in different +distributions to begin with. + +Imported as a top-level package — `from reference_payloads.schema_one import +...` — because pytest puts `tests/` on `sys.path`, the same arrangement that +makes `from conftest import ...` work throughout this suite. +""" + +from __future__ import annotations diff --git a/tests/reference_payloads/bootstrap.py b/tests/reference_payloads/bootstrap.py new file mode 100644 index 0000000..c1c3a3c --- /dev/null +++ b/tests/reference_payloads/bootstrap.py @@ -0,0 +1,48 @@ +"""The bootstrap distribution's reference payload: the homie schema document. + +`homie_schema.json` is the response of `span_panel_api.auth.get_homie_schema()`, +modelled by `V2HomieSchema`, and dispatch reads its `data_model_version` to +decide *which* adapter parses the panel at all. The parent/child device tree is +the other half of that story and lives in `schema_one`, beside the replay that +can interpret it. + +Read by path rather than through `importlib.resources`: this is a file in a test +tree now, not package data, and saying so in the loader is part of the point. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import json +from pathlib import Path + +from span_panel_api.models import HomieSchemaTypes + +_HOMIE_SCHEMA = Path(__file__).parent / "homie_schema.json" + + +def homie_schema() -> Mapping[str, object]: + """The captured `GET /api/v2/homie/schema` response, parsed. + + Taken from a live 32-space panel on `spanos2/r202603/05`; serial numbers are + masked. `typesSchemaHash` is `sha256:d347556a07d98f40`, which is the value + `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to. + """ + document: object = json.loads(_HOMIE_SCHEMA.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise TypeError(f"{_HOMIE_SCHEMA.name} is not a JSON object") + return document + + +def homie_schema_types() -> HomieSchemaTypes: + """The captured schema's `types` map. + + Separate from `homie_schema()` because this is the shape a field-metadata + build takes — `span_panel_api_schema_0.field_metadata.build_field_metadata` + accepts exactly this type — so a caller checking an adapter's output against + the schema never has to reach into an untyped document to get it. + """ + types = homie_schema()["types"] + if not isinstance(types, dict): + raise TypeError(f"{_HOMIE_SCHEMA.name} has no `types` object") + return types diff --git a/src/span_panel_api/reference_payloads/homie_schema.json b/tests/reference_payloads/homie_schema.json similarity index 100% rename from src/span_panel_api/reference_payloads/homie_schema.json rename to tests/reference_payloads/homie_schema.json diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json b/tests/reference_payloads/parent_child_tree.json similarity index 100% rename from packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json rename to tests/reference_payloads/parent_child_tree.json diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py b/tests/reference_payloads/schema_one.py similarity index 56% rename from packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py rename to tests/reference_payloads/schema_one.py index 05c360a..e3ce37a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py +++ b/tests/reference_payloads/schema_one.py @@ -1,25 +1,24 @@ -"""Reference wire payloads for the parent/child schema, shipped as package data. - -The counterpart to `span_panel_api.reference_payloads`, and here rather than -there for the reason that decides every placement in this workspace: a retained -topic tree is only interpretable by the parser that speaks its vocabulary, and -the eBus SDK that turns it back into devices is this distribution's dependency -alone. The bootstrap ships the document it fetches; this ships the tree it -cannot read. - -`devices_from_tree` is exported alongside the capture because a tree is not -directly usable — every consumer of it has to replay the retained topics -through `DiscoveredDevice` first, and that replay is the parser's own knowledge -of how the transport feeds it. Shipping the capture without the replay just -moves a copy of this module's logic into every consumer, which is the burden -the package data exists to remove. +"""The parent/child schema's reference payload, and the replay that reads it. + +`parent_child_tree.json` is a retained-topic capture: only the parser that +speaks its vocabulary can interpret it, and the eBus SDK that turns it back into +devices is `span-panel-api-schema-1`'s dependency alone. Importing this module +therefore reaches the SDK; importing `bootstrap` does not. + +`devices_from_tree` stays beside the capture for the reason it was written: a +tree is not directly usable, every consumer of it has to replay the retained +topics through `DiscoveredDevice` first, and separating the two would put the +same twelve lines in each of the test modules that read it. + +Read by path rather than through `importlib.resources`: this is a file in a test +tree now, not package data, and saying so in the loader is part of the point. """ from __future__ import annotations from collections.abc import Mapping -from importlib import resources import json +from pathlib import Path from ebus_sdk.homie import DiscoveredDevice @@ -30,8 +29,7 @@ wire exactly as the panel publishes it, and `update_description` parses it. """ -_PACKAGE = "span_panel_api_schema_1.reference_payloads" -_PARENT_CHILD_TREE = "parent_child_tree.json" +_PARENT_CHILD_TREE = Path(__file__).parent / "parent_child_tree.json" _DEFAULT_STATE = "ready" _DOMAIN = "ebus" @@ -41,13 +39,12 @@ def parent_child_tree() -> RetainedTopicTree: """The captured retained topics of a full 40-space panel. Thirteen devices: the panel, both lugs, a BESS with its MID, a PV, an EVSE - and the circuits — enough that a consumer can check what each device class - does and does not declare, including the absences. + and the circuits — enough that a test can check what each device class does + and does not declare, including the absences. """ - text = resources.files(_PACKAGE).joinpath(_PARENT_CHILD_TREE).read_text(encoding="utf-8") - tree: object = json.loads(text) + tree: object = json.loads(_PARENT_CHILD_TREE.read_text(encoding="utf-8")) if not isinstance(tree, dict): - raise TypeError(f"{_PARENT_CHILD_TREE} is not a JSON object") + raise TypeError(f"{_PARENT_CHILD_TREE.name} is not a JSON object") return tree @@ -74,7 +71,7 @@ def device_from_topics(device_id: str, topics: Mapping[str, str]) -> DiscoveredD def devices_from_tree(tree: RetainedTopicTree) -> list[DiscoveredDevice]: """Rebuild every device in a capture. - Takes the tree rather than reading it, so a consumer can filter the capture + Takes the tree rather than reading it, so a caller can filter the capture first — dropping the BESS to model a panel that has none, say — and still build devices the same way. """ diff --git a/tests/test_adoption.py b/tests/test_adoption.py index e01b6d1..eba7c70 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -17,9 +17,9 @@ from typing import TYPE_CHECKING import pytest +from reference_payloads.schema_one import device_from_topics, parent_child_tree from span_panel_api.models import SpanPanelSnapshot from span_panel_api_schema_1.adoption import MODELLED_TYPES -from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.snapshot import build_snapshot if TYPE_CHECKING: diff --git a/tests/test_catalog_divergence.py b/tests/test_catalog_divergence.py index e49577c..e22f72b 100644 --- a/tests/test_catalog_divergence.py +++ b/tests/test_catalog_divergence.py @@ -44,9 +44,9 @@ import json from pathlib import Path -from span_panel_api import reference_payloads as flat_payloads +from reference_payloads import bootstrap as flat_payloads +from reference_payloads import schema_one as tree_payloads from span_panel_api_schema_0.field_metadata import _PROPERTY_FIELD_MAP as _FLAT_FIELD_MAP -from span_panel_api_schema_1 import reference_payloads as tree_payloads from span_panel_api_schema_1.catalog import ( CATALOGUED_CONCRETE_UNITS, UNIT_FAMILIES, diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py index 9c20962..8c46b80 100644 --- a/tests/test_detection_auth.py +++ b/tests/test_detection_auth.py @@ -629,7 +629,7 @@ def test_panel_size_bad_format_raises(self): def test_panel_size_from_live_fixture(self): """panel_size works with the real panel schema fixture.""" - from span_panel_api.reference_payloads import homie_schema, homie_schema_types + from reference_payloads.bootstrap import homie_schema, homie_schema_types schema = V2HomieSchema( firmware_version=homie_schema()["firmwareVersion"], diff --git a/tests/test_packaging.py b/tests/test_packaging.py index e0b597a..b2f1e03 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -15,6 +15,23 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] +_CAPTURES = Path(__file__).parent / "reference_payloads" +"""Where the reference captures live, and the source of the names below. + +Read off the fixture directory rather than listed here so a capture added there +is covered the day it exists — the same reason `_wheel_source_packages` reads +the manifests. +""" + + +def _is_reference_capture(path: Path) -> bool: + """Would this file be one of the reference captures, wherever it sits? + + Two questions, because there are two ways to reintroduce the problem: move + the directory back inside a package, or drop one capture in beside a module. + """ + return "reference_payloads" in path.parts or path.name in {capture.name for capture in _CAPTURES.glob("*.json")} + def _wheel_source_packages() -> list[tuple[str, Path]]: """Every importable package each distribution in the workspace ships. @@ -67,3 +84,30 @@ def test_every_shipped_package_carries_a_py_typed_marker(distribution: str, pack """ marker = package_dir / "py.typed" assert marker.is_file(), f"{distribution} ships {package_dir.name} without a py.typed marker" + + +@pytest.mark.parametrize( + ("distribution", "package_dir"), + _wheel_source_packages(), + ids=lambda value: value.name if isinstance(value, Path) else str(value), +) +def test_no_shipped_package_carries_a_reference_capture(distribution: str, package_dir: Path) -> None: + """Test data must not sit inside a package directory. + + Nothing declares what ships: hatchling takes the whole of `packages = [...]`, + so a directory dropped inside one is package data by position alone. That is + how both wheels came to carry a reference capture between 3.0.0 and 3.1.0 — + 56 KB no runtime path reads, in every install, plus an import surface the + distributions never meant to promise and could not remove without a breaking + change. + + The captures are fixtures now, under `tests/reference_payloads`. This is the + cheap half of holding that: CI asserts the same thing against the built + wheels, where it is finally true rather than inferred, but a failure here + names the file before anyone builds one. + """ + payloads = [path for path in package_dir.rglob("*.json") if _is_reference_capture(path)] + assert not payloads, ( + f"{distribution} would ship {[str(p.relative_to(package_dir)) for p in payloads]} — " + "reference captures belong in tests/reference_payloads, not inside a package directory" + ) diff --git a/tests/test_reference_tree_values.py b/tests/test_reference_tree_values.py index 2b09f06..6c6d4d5 100644 --- a/tests/test_reference_tree_values.py +++ b/tests/test_reference_tree_values.py @@ -1,7 +1,6 @@ """What the reference tree leaves unvalued must be what the producer leaves unvalued. -`parent_child_tree.json` is shipped package data and the fixture every schema_1 -test is written against, so what it *publishes* is the whole evidence base for +`parent_child_tree.json` is the fixture every schema_1 test is written against, so what it *publishes* is the whole evidence base for "does this adapter read that property". A property the producer values and this capture does not is therefore invisible in both directions at once: no test can fail for not reading it, and no consumer test can fail for not surfacing it. @@ -56,7 +55,7 @@ import json import pathlib -from span_panel_api_schema_1.reference_payloads import parent_child_tree +from reference_payloads.schema_one import parent_child_tree _PANELBENCH_BASELINE = pathlib.Path(__file__).parent / "fixtures" / "panelbench_unvalued_by_both.json" diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 3facb13..f67933c 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -11,11 +11,11 @@ import pytest +from reference_payloads.schema_one import parent_child_tree from span_panel_api.adapters import _derive_required_members from span_panel_api.models import V2HomieSchema from span_panel_api.protocol import SchemaAdapter from span_panel_api_schema_1 import SchemaOneAdapter -from span_panel_api_schema_1.reference_payloads import parent_child_tree _TREE = parent_child_tree() diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index ea85ced..607325a 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -1,7 +1,7 @@ """Drive the parser end to end from what the simulator actually publishes. -Every other schema_1 test runs on the tree this distribution ships as package -data, which was captured off the upstream *generic* eBus panel simulator. That fixture is fine +Every other schema_1 test runs on the reference tree in +`tests/reference_payloads`, which was captured off the upstream *generic* eBus panel simulator. That fixture is fine for exercising the mapper, but it is not SPAN: it has never carried the extensions and divergences that are SPAN's own vocabulary, which is precisely the part a generic panel cannot produce. diff --git a/tests/test_schema_one_charge_limit.py b/tests/test_schema_one_charge_limit.py index 7991c0c..93ff7a4 100644 --- a/tests/test_schema_one_charge_limit.py +++ b/tests/test_schema_one_charge_limit.py @@ -24,13 +24,13 @@ from ebus_sdk.homie import DiscoveredDevice +from reference_payloads.schema_one import device_from_topics, parent_child_tree from span_panel_api.exceptions import SpanPanelServerError from span_panel_api.models import V2HomieSchema from span_panel_api_schema_1 import SchemaOneAdapter from span_panel_api_schema_1.charge_limit import resolve_charge_limit from span_panel_api_schema_1.devices import build_evse from span_panel_api_schema_1.field_metadata import build_field_metadata -from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree _TREE = parent_child_tree() diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py index 78ccfd9..8eb4484 100644 --- a/tests/test_schema_one_circuits.py +++ b/tests/test_schema_one_circuits.py @@ -1,6 +1,6 @@ """Mapping a v1.0 circuit device onto SpanCircuitSnapshot. -Driven from the tree this distribution ships as package data, captured off the +Driven from the reference tree in `tests/reference_payloads`, captured off the eBus emitter rather than hand-written, so the shapes are a conforming publisher's rather than my idea of them. See `scripts/capture_parent_child_reference.py` and the manifest beside it. @@ -21,7 +21,7 @@ from ebus_sdk.homie import DiscoveredDevice -from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from reference_payloads.schema_one import device_from_topics, parent_child_tree from span_panel_api_schema_1.circuits import build_circuit _TREE = parent_child_tree() diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index bc71172..ad866ee 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -551,6 +551,25 @@ def test_the_peer_is_pinned_to_the_same_specification_commit() -> None: ) +def test_the_recorded_capture_path_names_a_file_that_is_there() -> None: + """`produces.tree` is the one machine-readable statement of where the capture + lives, and three prose readers point at it — DEVELOPMENT.md, the payload + README and the capture script's own usage. + + A path recorded in a lockfile has no compiler: when the capture moved out of + the package directory in 3.1.0 nothing here objected, because nothing + resolved it. Resolving it is the whole check — a rename that leaves this + behind fails at the rename rather than the next time somebody re-captures. + """ + recorded = _peer(PANEL_SIM)["produces"] + assert isinstance(recorded, dict), "peers.ebus-panel-sim.produces should be an object" + tree = recorded["tree"] + assert isinstance(tree, str) + + capture = Path(__file__).parent.parent / tree + assert capture.is_file(), f"spec_lock.json records the reference tree at {tree}, which is not there" + + def test_the_peer_targets_the_same_firmware() -> None: """The firmware range is the anchor the two sides actually share — the spec says what a device class *may* publish, while a panel publishes one tree.""" @@ -741,7 +760,7 @@ def test_the_captured_tree_names_the_emitter_release_that_made_it() -> None: """The capture script, the lockfile and the checkout agree on one version. Three places could disagree, and the failure mode of each is the same: bytes - in the wheel attributed to a producer that did not make them. The script + in the tree attributed to a producer that did not make them. The script reads its expected version *out of this lockfile* rather than carrying a constant of its own, so there is one pin and this test proves the checkout is on it. diff --git a/tests/test_schema_one_connection_health.py b/tests/test_schema_one_connection_health.py index e2a9d94..8bf24d4 100644 --- a/tests/test_schema_one_connection_health.py +++ b/tests/test_schema_one_connection_health.py @@ -33,6 +33,11 @@ import pytest +from reference_payloads.schema_one import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) from span_panel_api.models import SpanEvseSnapshot, SpanPanelSnapshot from span_panel_api_schema_1.const import NODE_CONNECTION from span_panel_api_schema_1.devices import ( @@ -42,11 +47,6 @@ feed_connection_statuses, ) from span_panel_api_schema_1.field_metadata import build_field_metadata -from span_panel_api_schema_1.reference_payloads import ( - RetainedTopicTree, - device_from_topics, - parent_child_tree, -) from span_panel_api_schema_1.snapshot import build_snapshot PANEL = "example-40t-001" diff --git a/tests/test_schema_one_control_refusal.py b/tests/test_schema_one_control_refusal.py index cf8467a..ec56549 100644 --- a/tests/test_schema_one_control_refusal.py +++ b/tests/test_schema_one_control_refusal.py @@ -30,9 +30,9 @@ import pytest +from reference_payloads.schema_one import RetainedTopicTree, parent_child_tree from span_panel_api.models import V2HomieSchema from span_panel_api_schema_1 import SchemaOneAdapter -from span_panel_api_schema_1.reference_payloads import RetainedTopicTree, parent_child_tree _CATALOGS = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "catalogs" @@ -75,8 +75,8 @@ def _copy() -> dict[str, dict[str, str]]: """A writable copy of the capture. Copied rather than mutated in place because `parent_child_tree()` reads the - shipped package data once per process and every other module in the suite is - reading the same object. + capture once per process and every other module in the suite is reading the + same object. """ return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 574e185..8d69ba4 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -9,7 +9,7 @@ from ebus_sdk.homie import DiscoveredDevice -from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from reference_payloads.schema_one import device_from_topics, parent_child_tree from span_panel_api_schema_1.devices import ( build_mid, build_battery, diff --git a/tests/test_schema_one_discovery.py b/tests/test_schema_one_discovery.py index 4490527..d753710 100644 --- a/tests/test_schema_one_discovery.py +++ b/tests/test_schema_one_discovery.py @@ -30,6 +30,11 @@ from ebus_sdk.homie import DiscoveredDevice import pytest +from reference_payloads.schema_one import ( + device_from_topics, + devices_from_tree, + parent_child_tree, +) from span_panel_api.models import DiscoveredMetadata, SpanPanelSnapshot, is_discovery_path from span_panel_api_schema_1 import field_metadata as field_metadata_module from span_panel_api_schema_1.const import ( @@ -48,11 +53,6 @@ build_discovery, build_field_metadata, ) -from span_panel_api_schema_1.reference_payloads import ( - device_from_topics, - devices_from_tree, - parent_child_tree, -) from span_panel_api_schema_1.snapshot import build_snapshot PANEL_DEVICE_ID = "example-40t-001" diff --git a/tests/test_schema_one_extension.py b/tests/test_schema_one_extension.py index dac7f2e..969a78e 100644 --- a/tests/test_schema_one_extension.py +++ b/tests/test_schema_one_extension.py @@ -22,6 +22,7 @@ from ebus_sdk.homie import DiscoveredDevice import pytest +from reference_payloads.schema_one import device_from_topics, parent_child_tree from span_panel_api.models import ( ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, @@ -33,7 +34,6 @@ ) from span_panel_api_schema_1.extension import build_extension_properties from span_panel_api_schema_1.field_metadata import addressed_rows, build_discovery -from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.snapshot import build_snapshot PANEL_DEVICE_ID = "example-40t-001" diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 5ef643b..fd99493 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -1,6 +1,6 @@ """Panel-level mapping from the v1.0 tree. -Driven from the tree this distribution ships as package data, captured off a +Driven from the reference tree in `tests/reference_payloads`, captured off a real `panel_sim` parent/child tree. """ @@ -12,8 +12,8 @@ from ebus_sdk.homie import DiscoveredDevice +from reference_payloads.schema_one import device_from_topics, parent_child_tree from span_panel_api_schema_1.const import NODE_GRID, TYPE_BESS, TYPE_PV -from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.panel import ( PanelFields, build_unmapped_tabs, diff --git a/tests/test_schema_one_pcs.py b/tests/test_schema_one_pcs.py index 7dc611c..0a58d0a 100644 --- a/tests/test_schema_one_pcs.py +++ b/tests/test_schema_one_pcs.py @@ -27,14 +27,14 @@ from ebus_sdk.homie import DiscoveredDevice -from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, SpanPcsSnapshot -from span_panel_api_schema_1.const import PCS_LIMIT_SOURCES -from span_panel_api_schema_1.field_metadata import build_field_metadata -from span_panel_api_schema_1.reference_payloads import ( +from reference_payloads.schema_one import ( RetainedTopicTree, device_from_topics, parent_child_tree, ) +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, SpanPcsSnapshot +from span_panel_api_schema_1.const import PCS_LIMIT_SOURCES +from span_panel_api_schema_1.field_metadata import build_field_metadata from span_panel_api_schema_1.snapshot import build_snapshot PANEL = "example-40t-001" diff --git a/tests/test_schema_one_service_entrance.py b/tests/test_schema_one_service_entrance.py index 042413b..4985ac5 100644 --- a/tests/test_schema_one_service_entrance.py +++ b/tests/test_schema_one_service_entrance.py @@ -27,17 +27,17 @@ import pytest +from reference_payloads.schema_one import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) from span_panel_api.models import SpanPanelSnapshot from span_panel_api_schema_1.const import ( NODE_CONNECTION, PROP_FED_BY_DEVICE_ID, PROP_FED_BY_DEVICE_STATUS, ) -from span_panel_api_schema_1.reference_payloads import ( - RetainedTopicTree, - device_from_topics, - parent_child_tree, -) from span_panel_api_schema_1.snapshot import build_snapshot PANEL = "example-40t-001" diff --git a/tests/test_schema_one_shed_forecast.py b/tests/test_schema_one_shed_forecast.py index 08798b2..e1e8f02 100644 --- a/tests/test_schema_one_shed_forecast.py +++ b/tests/test_schema_one_shed_forecast.py @@ -17,13 +17,13 @@ from ebus_sdk.homie import DiscoveredDevice -from span_panel_api.models import FieldMetadata, SpanPanelSnapshot -from span_panel_api_schema_1.field_metadata import build_field_metadata -from span_panel_api_schema_1.reference_payloads import ( +from reference_payloads.schema_one import ( RetainedTopicTree, device_from_topics, parent_child_tree, ) +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot +from span_panel_api_schema_1.field_metadata import build_field_metadata from span_panel_api_schema_1.snapshot import build_snapshot PANEL = "example-40t-001" diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index f346c11..1f34063 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -6,8 +6,8 @@ from ebus_sdk.homie import DiscoveredDevice +from reference_payloads.schema_one import device_from_topics, parent_child_tree from span_panel_api.models import SpanPanelSnapshot -from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot _TREE = parent_child_tree() diff --git a/tests/test_schema_provenance.py b/tests/test_schema_provenance.py index 556a809..66e68c6 100644 --- a/tests/test_schema_provenance.py +++ b/tests/test_schema_provenance.py @@ -22,7 +22,7 @@ import pytest -from span_panel_api.reference_payloads import homie_schema +from reference_payloads.bootstrap import homie_schema from span_panel_api_schema_0 import const from span_panel_api_schema_0.field_metadata import _LUGS_FALLBACK, _PROPERTY_FIELD_MAP, _lookup_property