span-panel-api 2.6.4__tar.gz → 3.0.0b2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. span_panel_api-3.0.0b2/.github/workflows/ci.yml +136 -0
  2. span_panel_api-3.0.0b2/.github/workflows/release.yml +119 -0
  3. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.pre-commit-config.yaml +9 -2
  4. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/CHANGELOG.md +80 -0
  5. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/DEVELOPMENT.md +22 -0
  6. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/PKG-INFO +9 -2
  7. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/README.md +8 -1
  8. span_panel_api-3.0.0b2/RELEASE.md +193 -0
  9. span_panel_api-3.0.0b2/packages/schema-0/CHANGELOG.md +51 -0
  10. span_panel_api-3.0.0b2/packages/schema-0/README.md +32 -0
  11. span_panel_api-3.0.0b2/packages/schema-0/pyproject.toml +35 -0
  12. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/__init__.py +11 -0
  13. {span_panel_api-2.6.4/src/span_panel_api/mqtt → span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0}/accumulator.py +2 -1
  14. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/adapter.py +78 -0
  15. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/const.py +83 -0
  16. span_panel_api-2.6.4/src/span_panel_api/mqtt/homie.py → span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/consumer.py +9 -3
  17. {span_panel_api-2.6.4/src/span_panel_api/mqtt → span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0}/field_metadata.py +4 -57
  18. span_panel_api-3.0.0b2/packages/schema-1/CHANGELOG.md +60 -0
  19. span_panel_api-3.0.0b2/packages/schema-1/README.md +8 -0
  20. span_panel_api-3.0.0b2/packages/schema-1/pyproject.toml +42 -0
  21. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/__init__.py +6 -0
  22. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/adapter.py +260 -0
  23. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/circuits.py +177 -0
  24. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/const.py +115 -0
  25. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/devices.py +143 -0
  26. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +169 -0
  27. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/panel.py +291 -0
  28. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/py.typed +0 -0
  29. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +155 -0
  30. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/transport.py +194 -0
  31. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/pyproject.toml +44 -2
  32. span_panel_api-3.0.0b2/scripts/verify_adapterless_install.py +88 -0
  33. span_panel_api-3.0.0b2/scripts/verify_reconnect.py +530 -0
  34. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/__init__.py +5 -3
  35. span_panel_api-3.0.0b2/src/span_panel_api/adapters.py +201 -0
  36. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/auth.py +9 -0
  37. span_panel_api-3.0.0b2/src/span_panel_api/dispatch.py +74 -0
  38. span_panel_api-3.0.0b2/src/span_panel_api/exceptions.py +105 -0
  39. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/factory.py +26 -2
  40. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/models.py +8 -0
  41. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/mqtt/__init__.py +5 -6
  42. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/mqtt/client.py +164 -65
  43. span_panel_api-3.0.0b2/src/span_panel_api/mqtt/const.py +30 -0
  44. span_panel_api-3.0.0b2/src/span_panel_api/protocol.py +155 -0
  45. span_panel_api-3.0.0b2/src/span_panel_api/py.typed +0 -0
  46. span_panel_api-3.0.0b2/src/span_panel_api/schema_drift.py +65 -0
  47. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/conftest.py +37 -11
  48. span_panel_api-3.0.0b2/tests/fixtures/parent_child_tree.json +225 -0
  49. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_accumulator.py +2 -2
  50. span_panel_api-3.0.0b2/tests/test_adapters_discovery.py +350 -0
  51. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_auth_and_homie_helpers.py +2 -2
  52. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_detection_auth.py +48 -0
  53. span_panel_api-3.0.0b2/tests/test_factory_dispatch.py +310 -0
  54. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_field_metadata.py +2 -1
  55. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_mqtt_client_connection.py +89 -17
  56. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_mqtt_connect_flow.py +14 -17
  57. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_mqtt_homie.py +24 -26
  58. span_panel_api-3.0.0b2/tests/test_packaging.py +69 -0
  59. span_panel_api-3.0.0b2/tests/test_protocol_conformance.py +111 -0
  60. span_panel_api-3.0.0b2/tests/test_public_api_unchanged.py +87 -0
  61. span_panel_api-3.0.0b2/tests/test_schema_one_adapter.py +304 -0
  62. span_panel_api-3.0.0b2/tests/test_schema_one_circuits.py +194 -0
  63. span_panel_api-3.0.0b2/tests/test_schema_one_devices.py +169 -0
  64. span_panel_api-3.0.0b2/tests/test_schema_one_panel.py +266 -0
  65. span_panel_api-3.0.0b2/tests/test_schema_one_snapshot.py +136 -0
  66. span_panel_api-3.0.0b2/tests/test_schema_one_transport.py +233 -0
  67. span_panel_api-3.0.0b2/tests/test_schema_provenance.py +177 -0
  68. span_panel_api-3.0.0b2/tests/test_schema_zero_adapter.py +55 -0
  69. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/uv.lock +67 -24
  70. span_panel_api-2.6.4/.github/workflows/ci.yml +0 -101
  71. span_panel_api-2.6.4/.github/workflows/release.yml +0 -42
  72. span_panel_api-2.6.4/src/span_panel_api/exceptions.py +0 -42
  73. span_panel_api-2.6.4/src/span_panel_api/mqtt/const.py +0 -71
  74. span_panel_api-2.6.4/src/span_panel_api/protocol.py +0 -79
  75. span_panel_api-2.6.4/tests/test_protocol_conformance.py +0 -48
  76. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.codefactor +0 -0
  77. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.codefactor.yml +0 -0
  78. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.deps-installed +0 -0
  79. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  80. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  81. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.github/dependabot.yml +0 -0
  82. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.github/workflows/dependabot-auto-approve.yml +0 -0
  83. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.github/workflows/dependabot-auto-merge.yml +0 -0
  84. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.gitignore +0 -0
  85. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.markdownlint-cli2.jsonc +0 -0
  86. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.markdownlint.json +0 -0
  87. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.prettierrc.json +0 -0
  88. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.vscode/extensions.json +0 -0
  89. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/.vscode/tasks.json +0 -0
  90. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/LICENSE +0 -0
  91. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/SECURITY.md +0 -0
  92. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/conftest.py +0 -0
  93. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/developer_attribute_readme.md +0 -0
  94. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/openapi.json +0 -0
  95. {span_panel_api-2.6.4/src/span_panel_api → span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0}/py.typed +0 -0
  96. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/pytest.ini +0 -0
  97. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/pytest_output.log +0 -0
  98. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/scripts/__init__.py +0 -0
  99. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/scripts/coverage.py +0 -0
  100. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/scripts/format.sh +0 -0
  101. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/scripts/format_markdown.py +0 -0
  102. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/scripts/test_live_auth.py +0 -0
  103. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/setup-hooks.sh +0 -0
  104. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/_http.py +0 -0
  105. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/const.py +0 -0
  106. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/detection.py +0 -0
  107. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/mqtt/async_client.py +0 -0
  108. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/mqtt/connection.py +0 -0
  109. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/mqtt/models.py +0 -0
  110. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/src/span_panel_api/phase_validation.py +0 -0
  111. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  112. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  113. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  114. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/fixtures/v2/README.md +0 -0
  115. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/fixtures/v2/homie_schema.json +0 -0
  116. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/fixtures/v2/status.json +0 -0
  117. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/simulation_fixtures/circuits.response.txt +0 -0
  118. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/simulation_fixtures/panel.response.txt +0 -0
  119. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/simulation_fixtures/soe.response.txt +0 -0
  120. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/simulation_fixtures/status.response.txt +0 -0
  121. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_async_mqtt_client.py +0 -0
  122. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_exceptions.py +0 -0
  123. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_mqtt_bridge.py +0 -0
  124. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_mqtt_debounce.py +0 -0
  125. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_phase_validation_configs.py +0 -0
  126. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_phase_validation_errors.py +0 -0
  127. {span_panel_api-2.6.4 → span_panel_api-3.0.0b2}/tests/test_protocol_models.py +0 -0
@@ -0,0 +1,136 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, develop ]
6
+ pull_request:
7
+ branches: [ main, develop ]
8
+ # Push and pull_request both arrive by webhook, so a dropped delivery leaves a
9
+ # commit with no run at all -- which reads the same as a commit that passed.
10
+ # Dispatch re-runs this against any ref on demand.
11
+ #
12
+ # gh workflow run ci.yml --ref develop
13
+ workflow_dispatch:
14
+
15
+ jobs:
16
+ lint-and-test:
17
+ runs-on: ubuntu-latest
18
+ strategy:
19
+ matrix:
20
+ python-version: ["3.14"]
21
+
22
+ steps:
23
+ - name: Checkout code
24
+ uses: actions/checkout@v7
25
+
26
+ - name: Set up Python ${{ matrix.python-version }}
27
+ uses: actions/setup-python@v7
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+
31
+ - name: Install uv
32
+ uses: astral-sh/setup-uv@v7
33
+ with:
34
+ enable-cache: true
35
+
36
+ - name: Install dependencies
37
+ run: uv sync --all-packages
38
+
39
+ - name: Run pre-commit hooks
40
+ run: |
41
+ uv run pre-commit run --all-files
42
+
43
+ - name: Run tests with pytest
44
+ run: |
45
+ uv run pytest tests/ -v \
46
+ --cov=src/span_panel_api \
47
+ --cov=packages/schema-0/src/span_panel_api_schema_0 \
48
+ --cov-report=xml --cov-report=term-missing
49
+
50
+
51
+
52
+ security-scan:
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - name: Checkout code
56
+ uses: actions/checkout@v7
57
+
58
+ - name: Set up Python
59
+ uses: actions/setup-python@v7
60
+ with:
61
+ python-version: "3.14"
62
+
63
+ - name: Install uv
64
+ uses: astral-sh/setup-uv@v7
65
+ with:
66
+ enable-cache: true
67
+
68
+ - name: Install dependencies
69
+ run: uv sync --all-packages
70
+
71
+ - name: Run Bandit security scan
72
+ run: |
73
+ uv run bandit -r src/ packages/ -f json -o bandit-report.json || true
74
+
75
+ - name: Upload Bandit scan results
76
+ uses: actions/upload-artifact@v7
77
+ with:
78
+ name: bandit-report
79
+ path: bandit-report.json
80
+
81
+ build-check:
82
+ runs-on: ubuntu-latest
83
+ steps:
84
+ - name: Checkout code
85
+ uses: actions/checkout@v7
86
+
87
+ - name: Set up Python
88
+ uses: actions/setup-python@v7
89
+ with:
90
+ python-version: "3.14"
91
+
92
+ - name: Install uv
93
+ uses: astral-sh/setup-uv@v7
94
+ with:
95
+ enable-cache: true
96
+
97
+ - name: Install dependencies
98
+ run: uv sync --all-packages
99
+
100
+ - name: Build packages
101
+ run: uv build --all-packages
102
+
103
+ - name: Check packages
104
+ run: uv run twine check dist/*
105
+
106
+ # Every distribution here is fully annotated, so every distribution has to
107
+ # carry the marker that lets a consumer's type checker see those annotations.
108
+ # Without it the package resolves to Any downstream and the typing is inert.
109
+ - name: Verify every wheel ships a py.typed marker
110
+ run: |
111
+ python -c "
112
+ import glob, sys, zipfile
113
+ wheels = glob.glob('dist/*.whl')
114
+ if not wheels:
115
+ sys.exit('::error::no wheels were built')
116
+ for wheel in wheels:
117
+ if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()):
118
+ sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any')
119
+ print(f'{wheel}: py.typed present')
120
+ "
121
+
122
+ # The configuration entry-point discovery exists to support, and the one
123
+ # nothing else in CI exercises: the bootstrap wheel installed with no
124
+ # adapter present. It must import, and it must fail by name rather than
125
+ # with ModuleNotFoundError.
126
+ - name: Verify the bootstrap installs without an adapter
127
+ run: |
128
+ uv venv /tmp/bootstrap-only
129
+ VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl
130
+ VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project python scripts/verify_adapterless_install.py
131
+
132
+ - name: Upload build artifacts
133
+ uses: actions/upload-artifact@v7
134
+ with:
135
+ name: dist
136
+ path: dist/
@@ -0,0 +1,119 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ # A `release: published` event reaches this workflow only through webhook
7
+ # delivery, and a dropped delivery is silent: the release exists, the tag
8
+ # exists, nothing publishes, and the run list looks the same as it did before.
9
+ # Dispatch goes through the API instead, so a release can always be driven to
10
+ # PyPI by hand.
11
+ #
12
+ # gh workflow run release.yml --ref schema-1-v0.1.0b1
13
+ #
14
+ # Dispatch a tag, never a branch. The steps below read the distribution and
15
+ # version out of the tag name, so a branch ref carries neither; it falls
16
+ # through to the error case rather than being guessed at.
17
+ workflow_dispatch:
18
+
19
+ # This repo publishes two distributions that version independently: the
20
+ # bootstrap (span-panel-api) and each schema adapter (span-panel-api-schema-N).
21
+ # One release publishes exactly one of them, chosen by the tag prefix:
22
+ #
23
+ # v3.0.0b1 -> span-panel-api (the historical convention)
24
+ # schema-0-v1.0.0b1 -> span-panel-api-schema-0
25
+ #
26
+ # The version lives in the distribution's own pyproject.toml and this workflow
27
+ # only verifies the tag agrees. It deliberately does not rewrite the version at
28
+ # release time: the adapter declares a floor on the bootstrap
29
+ # (span-panel-api>=X), so the committed versions are load-bearing for resolution
30
+ # and cannot be treated as placeholders that a release stamps over.
31
+ #
32
+ # Publishing uses PyPI trusted publishing, which is configured per project. A
33
+ # distribution released here for the first time needs a pending publisher
34
+ # created on PyPI beforehand (project name, this repo, workflow `release.yml`,
35
+ # environment `release`); without it the publish step fails on an otherwise
36
+ # correct build.
37
+ jobs:
38
+ deploy:
39
+ runs-on: ubuntu-latest
40
+ environment: release
41
+ permissions:
42
+ id-token: write # For trusted publishing to PyPI
43
+
44
+ steps:
45
+ - name: Checkout code
46
+ uses: actions/checkout@v7
47
+
48
+ - name: Set up Python
49
+ uses: actions/setup-python@v7
50
+ with:
51
+ python-version: "3.14"
52
+
53
+ - name: Install uv
54
+ uses: astral-sh/setup-uv@v7
55
+ with:
56
+ enable-cache: true
57
+
58
+ - name: Resolve the distribution from the tag
59
+ id: target
60
+ run: |
61
+ TAG=${GITHUB_REF#refs/tags/}
62
+ case "$TAG" in
63
+ schema-*-v*)
64
+ SCHEMA=${TAG#schema-}
65
+ SCHEMA=${SCHEMA%%-v*}
66
+ PACKAGE="span-panel-api-schema-$SCHEMA"
67
+ MANIFEST="packages/schema-$SCHEMA/pyproject.toml"
68
+ VERSION=${TAG#schema-$SCHEMA-v}
69
+ ;;
70
+ v*)
71
+ PACKAGE="span-panel-api"
72
+ MANIFEST="pyproject.toml"
73
+ VERSION=${TAG#v}
74
+ ;;
75
+ *)
76
+ echo "::error::Tag '$TAG' names no distribution. Use 'vX.Y.Z' for the bootstrap or 'schema-N-vX.Y.Z' for an adapter."
77
+ exit 1
78
+ ;;
79
+ esac
80
+ if [ ! -f "$MANIFEST" ]; then
81
+ echo "::error::Tag '$TAG' resolves to '$MANIFEST', which does not exist."
82
+ exit 1
83
+ fi
84
+ echo "Tag '$TAG' releases $PACKAGE $VERSION from $MANIFEST"
85
+ echo "package=$PACKAGE" >> "$GITHUB_OUTPUT"
86
+ echo "manifest=$MANIFEST" >> "$GITHUB_OUTPUT"
87
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
88
+
89
+ - name: Verify the tag matches the committed version
90
+ run: |
91
+ DECLARED=$(python -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['version'])" "${{ steps.target.outputs.manifest }}")
92
+ if [ "$DECLARED" != "${{ steps.target.outputs.version }}" ]; then
93
+ echo "::error::${{ steps.target.outputs.manifest }} declares version '$DECLARED' but the tag says '${{ steps.target.outputs.version }}'. Commit the version bump before tagging."
94
+ exit 1
95
+ fi
96
+ echo "Version $DECLARED confirmed."
97
+
98
+ # Only the tagged distribution is built, so dist/ holds exactly what this
99
+ # release publishes and the publish step cannot pick up a sibling package.
100
+ - name: Build package
101
+ run: uv build --package "${{ steps.target.outputs.package }}"
102
+
103
+ - name: Verify the wheel ships a py.typed marker
104
+ run: |
105
+ python -c "
106
+ import glob, sys, zipfile
107
+ wheels = glob.glob('dist/*.whl')
108
+ if not wheels:
109
+ sys.exit('::error::no wheel was built')
110
+ for wheel in wheels:
111
+ if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()):
112
+ sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any')
113
+ print(f'{wheel}: py.typed present')
114
+ "
115
+
116
+ - name: Publish to PyPI
117
+ uses: pypa/gh-action-pypi-publish@release/v1
118
+ with:
119
+ print-hash: true
@@ -77,6 +77,10 @@ repos:
77
77
  - pytest
78
78
  - types-PyYAML
79
79
  - paho-mqtt
80
+ # schema-1 parses the parent/child tree with the eBus SDK, which
81
+ # ships py.typed — so the hook needs it installed to resolve those
82
+ # types rather than silently reporting import-not-found.
83
+ - ebus-sdk>=0.18.0
80
84
  args: ['--config-file=pyproject.toml']
81
85
  exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*'
82
86
 
@@ -92,6 +96,9 @@ repos:
92
96
  - pytest
93
97
  - pyyaml
94
98
  - paho-mqtt
99
+ # schema-1 imports the eBus SDK; without it here the hook reports
100
+ # import-error for a dependency that is correctly declared.
101
+ - ebus-sdk>=0.18.0
95
102
  exclude: '^src/span_panel_api/generated_client/.*|tests/.*|generate_client\.py|scripts/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*'
96
103
 
97
104
  # Check for common security issues
@@ -108,7 +115,7 @@ repos:
108
115
  hooks:
109
116
  - id: vulture
110
117
  name: vulture
111
- entry: bash -c 'uv run vulture src/span_panel_api/ --min-confidence 80'
118
+ entry: bash -c 'uv run vulture src/span_panel_api/ packages/schema-0/src/span_panel_api_schema_0/ packages/schema-1/src/span_panel_api_schema_1/ --min-confidence 80'
112
119
  language: system
113
120
  types: [python]
114
121
  pass_filenames: false
@@ -131,6 +138,6 @@ repos:
131
138
  name: coverage summary
132
139
  entry: bash
133
140
  language: system
134
- args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"']
141
+ args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov=packages/schema-0/src/span_panel_api_schema_0 --cov=packages/schema-1/src/span_panel_api_schema_1 --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"']
135
142
  pass_filenames: false
136
143
  verbose: true
@@ -4,6 +4,86 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.0.0b2] - 08/2026
8
+
9
+ Pre-release. Releases the reshaped `SchemaAdapter` protocol that `3.0.0b1` predates, and makes the mismatch between the two detectable rather than fatal at construction.
10
+
11
+ ### Added
12
+
13
+ - **Adapter contract versioning.** `SchemaAdapter` now requires an `ADAPTER_CONTRACT` integer, and discovery rejects any adapter that does not declare this package's `ADAPTER_CONTRACT_VERSION`. Member presence was never the whole contract: a Protocol
14
+ cannot express signatures at runtime, so an adapter carrying every required name and the previous `__init__` arity passed discovery and failed much later inside the transport, as a bare `TypeError` about an argument count — the least actionable moment to
15
+ learn that two installed packages were built against different versions of each other. Adapters must declare the value as a **literal**; one read from the installed bootstrap would agree with every bootstrap, which is the disagreement being looked for.
16
+ - **`SpanPanelAdapterIncompatibleError`**, raised when the adapter a panel needs is installed but unusable. Distinct from `SpanPanelAdapterMissingError` because the remedy inverts: missing means install something, incompatible means installing more cannot
17
+ help. Reporting the second as the first sends someone to install a package they already have. Discovery still only _logs_ a rejection, so one unusable third-party adapter cannot take down a panel whose own adapter is fine; the error surfaces only when
18
+ the rejected adapter turns out to be the one required.
19
+
20
+ ### Fixed
21
+
22
+ - **`data-model-version` dispatch is live.** The factory hardcoded `None`, so the guard that refuses a parent/child panel was written, tested and never invoked — every panel resolved to the flat parser regardless of what it reported. The Homie schema is
23
+ now fetched over REST **before** the broker is opened and the version drives adapter selection, which SPAN confirmed is a reliable flat-versus-parent/child signal on that endpoint. A `1.0` panel now raises `SpanPanelAdapterMissingError` naming the
24
+ adapter to install, instead of dying inside the flat parser on a missing `energy.ebus.device.circuit/space` property.
25
+ - **A directly constructed `SpanMqttClient` dispatches too.** Building a client without `create_span_client` previously always resolved the flat adapter, so it carried the same defect the factory path had. Dispatch now happens wherever a parser is built,
26
+ and fills in `data_model_version` / `schema_dispatch_reason` rather than leaving them reading `"not dispatched"`.
27
+
28
+ ### Changed
29
+
30
+ - **BREAKING: `SchemaAdapter.__init__` takes the schema, not a panel size.** `adapter_cls(serial_number, schema)` replaces `adapter_cls(serial_number, panel_size)`. `panel_size` is read out of a block only the flat schema has, so the bootstrap had to
31
+ understand a wire format it is meant to know nothing about, and an adapter whose schema is shaped differently had no way to say so. Each adapter now reads what its own format defines.
32
+ - **BREAKING: `SchemaAdapter.build_field_metadata()` takes no arguments.** It previously received `schema.types` — again a flat-shaped parameter on a format-agnostic protocol. The adapter holds the schema it was constructed with.
33
+ - **`V2HomieSchema.data_model_version`** carries the `dataModelVersion` field, `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string.
34
+ - **Tier 1 dispatch moved to `span_panel_api.dispatch.select_adapter_key`** from the private `factory._select_adapter_key`, so the transport can dispatch without importing the factory. `adapters.py` continues to answer "what is installed"; the new module
35
+ answers "what does this panel need".
36
+
37
+ ## [3.0.0b1] - 08/2026
38
+
39
+ Pre-release. `span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves via entry points, so support for a new panel schema arrives by installing a package rather
40
+ than by upgrading the transport. This is prototype work being proven end to end before any decision to land it on `main`.
41
+
42
+ ### Removed
43
+
44
+ - **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. Flat-schema panels (firmware `r202603`–`r202627`) need **`span-panel-api-schema-0`** installed
45
+ alongside it:
46
+
47
+ ```console
48
+ pip install span-panel-api span-panel-api-schema-0
49
+ ```
50
+
51
+ - **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters
52
+ every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description
53
+ received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`.
54
+ - **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced before the Phase 0 relocation) and `TYPE_PCS` (a real schema type this library does not consume).
55
+
56
+ ### Added
57
+
58
+ - **`span_panel_api.adapters.resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both Tier 1 dispatch and the transport's default path.
59
+ - **`SpanPanelSchemaVersionError`**, raised when a panel reports a `data-model-version` whose schema major cannot be determined. Distinct from `SpanPanelAdapterMissingError` because the remedy differs: a missing adapter is a known schema with no installed
60
+ parser, while this is a schema no adapter can even be named for.
61
+ - **`SpanPanelAdapterMissingError` and `SpanPanelSchemaVersionError` are now exported** from the top-level package — both are errors a user sees when their panel outruns their install, so catching them should not require reaching into a private module.
62
+ - **`SchemaAdapter.__init__` is declared on the protocol.** Construction was always part of the contract (the transport resolves an adapter class from the registry and calls it), but was previously typed only as a `Callable`, leaving the signature
63
+ unchecked against implementations.
64
+ - **Entry-point validation.** `discover_adapters()` now verifies each loaded object is a class implementing the protocol before registering it, and skips it with a logged reason otherwise. One broken third-party adapter cannot take down a panel whose own
65
+ adapter is fine.
66
+ - **`scripts/verify_adapterless_install.py`** and a CI step that runs it against a venv holding only the bootstrap wheel.
67
+
68
+ ### Changed
69
+
70
+ - **`SpanMqttClient(adapter_factory=...)` is now optional.** When omitted, the parser is resolved through entry-point discovery at `_build_adapter()` rather than imported. Resolution is lazy by design: constructing a client must not require an adapter to
71
+ be installed, only building a parser must.
72
+ - **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be
73
+ 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
74
+ wrong power and energy figures.
75
+ - **Dispatch diagnostics travel through the `SpanMqttClient` constructor**, removing the window where a connected client reported a selected adapter alongside `schema_dispatch_reason='not dispatched'`.
76
+ - **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
77
+ 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
78
+ 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.
79
+
80
+ ### Fixed
81
+
82
+ - **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
83
+ wheel built without one.
84
+ - **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
85
+ derivation would have quietly stopped requiring such a member the day the protocol declared one.
86
+
7
87
  ## [2.6.4] - 05/2026
8
88
 
9
89
  ### Fixed
@@ -70,6 +70,28 @@ To install pre-commit hooks:
70
70
 
71
71
  This installs dependencies (if needed) and configures git pre-commit hooks.
72
72
 
73
+ ## Workspace layout
74
+
75
+ 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
76
+ distribution together.
77
+
78
+ To work with the packages individually:
79
+
80
+ ```bash
81
+ # Install the workspace including every member
82
+ uv sync --all-packages
83
+
84
+ # Build every distribution
85
+ uv build --all-packages
86
+
87
+ # Build just one
88
+ uv build --package span-panel-api-schema-0
89
+ ```
90
+
91
+ ## Releasing
92
+
93
+ See [RELEASE.md](RELEASE.md) — each distribution versions and publishes independently, and the tag name selects which one is published.
94
+
73
95
  ## Contributing
74
96
 
75
97
  1. Fork and clone the repository
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: span-panel-api
3
- Version: 2.6.4
3
+ Version: 3.0.0b2
4
4
  Summary: A client library for SPAN Panel API
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -38,10 +38,17 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim
38
38
 
39
39
  ## Installation
40
40
 
41
+ 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`.
42
+
41
43
  ```bash
42
- pip install span-panel-api
44
+ pip install span-panel-api span-panel-api-schema-0
43
45
  ```
44
46
 
47
+ `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
48
+ not find and lists what is installed.
49
+
50
+ 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).
51
+
45
52
  ### Dependencies
46
53
 
47
54
  - `httpx` — v2 authentication and detection endpoints
@@ -23,10 +23,17 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim
23
23
 
24
24
  ## Installation
25
25
 
26
+ 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`.
27
+
26
28
  ```bash
27
- pip install span-panel-api
29
+ pip install span-panel-api span-panel-api-schema-0
28
30
  ```
29
31
 
32
+ `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
33
+ not find and lists what is installed.
34
+
35
+ 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).
36
+
30
37
  ### Dependencies
31
38
 
32
39
  - `httpx` — v2 authentication and detection endpoints
@@ -0,0 +1,193 @@
1
+ # Releasing
2
+
3
+ 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
4
+ administrator has to do to release everything.
5
+
6
+ ## Layout
7
+
8
+ One repository, one [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/), independent distributions:
9
+
10
+ | Distribution | Directory | Manifest | Purpose |
11
+ | ------------------------- | -------------------- | ---------------------------------- | ----------------------------------------------------------------------- |
12
+ | `span-panel-api` | repository root | `pyproject.toml` | The **bootstrap** — transport, dispatch, protocols. Contains no parser. |
13
+ | `span-panel-api-schema-0` | `packages/schema-0/` | `packages/schema-0/pyproject.toml` | Flat-schema parser (firmware `r202603`–`r202627`) |
14
+
15
+ 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
16
+ `packages/schema-N/`.
17
+
18
+ Consequences for releasing:
19
+
20
+ - **Each distribution has its own version number** in its own manifest.
21
+ - **Each distribution is its own PyPI project**, with its own trusted publisher.
22
+ - **A release publishes exactly one distribution.** Releasing "the repo" means cutting one release per distribution.
23
+
24
+ ## Two version axes
25
+
26
+ The bootstrap and the adapters do not share a version, and this is deliberate rather than an oversight.
27
+
28
+ - **The bootstrap** versions on its own library API — the transport and the `SchemaAdapter` protocol.
29
+ - **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.
30
+
31
+ 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.
32
+
33
+ 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.
34
+
35
+ ## How a tag selects a distribution
36
+
37
+ `.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:
38
+
39
+ | Tag | Distribution published | Manifest read |
40
+ | ----------------- | ------------------------- | ---------------------------------- |
41
+ | `vX.Y.Z` | `span-panel-api` | `pyproject.toml` |
42
+ | `schema-N-vX.Y.Z` | `span-panel-api-schema-N` | `packages/schema-N/pyproject.toml` |
43
+
44
+ Worked example for `schema-0-v1.0.0b1`:
45
+
46
+ ```text
47
+ TAG = schema-0-v1.0.0b1
48
+ ${TAG#schema-} → 0-v1.0.0b1 strip leading "schema-"
49
+ ${SCHEMA%%-v*} → 0 strip trailing "-v…" ⇒ schema number
50
+ PACKAGE = span-panel-api-schema-0
51
+ MANIFEST = packages/schema-0/pyproject.toml
52
+ VERSION = ${TAG#schema-0-v} → 1.0.0b1
53
+ ```
54
+
55
+ 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.
56
+
57
+ A tag matching neither form (`1.2.3`, `nightly`) fails immediately with a message naming both accepted forms.
58
+
59
+ ## The tag does not set the version
60
+
61
+ The workflow **verifies** the version; it does not write it.
62
+
63
+ ```text
64
+ tag schema-0-v1.0.0b1
65
+ ⇒ packages/schema-0/pyproject.toml must declare version = "1.0.0b1"
66
+ ⇒ otherwise the job fails without publishing
67
+ ```
68
+
69
+ 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
70
+ dependency floor on the bootstrap means a stamped version could silently disagree with what resolution actually uses.
71
+
72
+ 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.
73
+
74
+ ## Releasing one distribution
75
+
76
+ 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).
77
+ 2. **Merge to `develop`** (or `main`, once this work is no longer prototype) and let CI go green.
78
+ 3. **Create a GitHub Release:**
79
+ - **Tag** — `vX.Y.Z` or `schema-N-vX.Y.Z`, per the table above.
80
+ - **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.
81
+ - **Set as a pre-release** — tick this for any `aN` / `bN` / `rcN` version.
82
+ 4. **Watch the run.** `gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')"`
83
+
84
+ The job prints exactly what it resolved, which is the first thing to read if something looks wrong:
85
+
86
+ ```text
87
+ Tag 'schema-0-v1.0.0b1' releases span-panel-api-schema-0 1.0.0b1 from packages/schema-0/pyproject.toml
88
+ Version 1.0.0b1 confirmed.
89
+ ```
90
+
91
+ ## Releasing every distribution
92
+
93
+ 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.
94
+
95
+ To release the whole workspace:
96
+
97
+ 1. Bump every manifest that changed, in one branch, with its changelog entry.
98
+ 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.
99
+ 3. Merge and let CI go green.
100
+ 4. Cut the releases **bootstrap first, then each adapter**:
101
+
102
+ ```text
103
+ v3.0.0b1 → span-panel-api
104
+ schema-0-v1.0.0b1 → span-panel-api-schema-0
105
+ schema-1-v0.1.0 → span-panel-api-schema-1
106
+ ```
107
+
108
+ 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.
109
+
110
+ 5. Verify from PyPI rather than from CI — see below.
111
+
112
+ Only bump and release what actually changed. A distribution with no changes does not need a release just because a sibling had one.
113
+
114
+ ## Adding a new adapter
115
+
116
+ When `packages/schema-N/` lands, the workflow needs no edit — but PyPI does, and this is the step that will be forgotten:
117
+
118
+ 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:
119
+
120
+ | Field | Value |
121
+ | ----------------- | ------------------------- |
122
+ | PyPI Project Name | `span-panel-api-schema-N` |
123
+ | Owner | `SpanPanel` |
124
+ | Repository name | `span-panel-api` |
125
+ | Workflow name | `release.yml` |
126
+ | Environment name | `release` |
127
+
128
+ 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
129
+ `https://pypi.org/manage/project/<name>/settings/publishing/`.
130
+
131
+ 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.
132
+ 3. **Ship a `py.typed` marker** in the new package. CI fails the build without it.
133
+
134
+ 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
135
+ sibling that could be uploaded by accident.
136
+
137
+ ## What the workflow checks
138
+
139
+ In order, all before anything is uploaded:
140
+
141
+ 1. **Tag names a known distribution** — otherwise fail, naming both accepted forms.
142
+ 2. **The derived manifest exists** — catches a `schema-N` tag with no matching directory.
143
+ 3. **The tag version equals the committed version** — read with `tomllib`, compared exactly.
144
+ 4. **Only the tagged package is built** — `uv build --package <name>`, so `dist/` holds exactly one distribution.
145
+ 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.
146
+
147
+ | Failure | Meaning |
148
+ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
149
+ | `Tag '…' names no distribution` | Tag is malformed. Use `vX.Y.Z` or `schema-N-vX.Y.Z`. |
150
+ | `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. |
151
+ | `declares version 'A' but the tag says 'B'` | The bump was not committed, or the release targets the wrong branch. |
152
+ | `ships no py.typed marker` | The new package is missing the marker file. |
153
+ | 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. |
154
+
155
+ 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.
156
+
157
+ ## Verifying a release
158
+
159
+ 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:
160
+
161
+ ```bash
162
+ # 1. The bootstrap alone must fail by name, not with ModuleNotFoundError
163
+ python3 -m venv .solo && ./.solo/bin/pip install --pre span-panel-api
164
+ ./.solo/bin/python -c "
165
+ from span_panel_api.adapters import discover_adapters, resolve_adapter, DEFAULT_ADAPTER_KEY
166
+ from span_panel_api.exceptions import SpanPanelAdapterMissingError
167
+ print('adapters:', sorted(discover_adapters()))
168
+ try:
169
+ resolve_adapter(DEFAULT_ADAPTER_KEY, 'release check')
170
+ except SpanPanelAdapterMissingError as exc:
171
+ print('raised as designed:', exc.needed, exc.available)
172
+ "
173
+
174
+ # 2. Both packages: the adapter resolves through discovery
175
+ python3 -m venv .both && ./.both/bin/pip install --pre span-panel-api span-panel-api-schema-0
176
+ ./.both/bin/python -c "
177
+ from span_panel_api.adapters import discover_adapters
178
+ print('adapters:', sorted(discover_adapters()))
179
+ "
180
+ ```
181
+
182
+ Expected: `adapters: []` then a named `SpanPanelAdapterMissingError` in the first, `adapters: ['schema_0']` in the second.
183
+
184
+ Drop `--pre` once the versions being verified are not pre-releases.
185
+
186
+ ## Pre-releases
187
+
188
+ Versions like `3.0.0b1` are pre-releases in both places that matter:
189
+
190
+ - **PyPI** will not install them without `--pre`, so `pip install span-panel-api` continues to resolve the last stable release.
191
+ - **GitHub** should have "Set as a pre-release" ticked, which keeps them out of the repository's "Latest release" slot.
192
+
193
+ The publish workflow itself does not care — `on: release: published` fires either way.