obi-energy-tracker 0.1.0__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 (35) hide show
  1. obi_energy_tracker-0.1.0/.gitattributes +1 -0
  2. obi_energy_tracker-0.1.0/.github/CODEOWNERS +1 -0
  3. obi_energy_tracker-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +45 -0
  4. obi_energy_tracker-0.1.0/.github/ISSUE_TEMPLATE/config.yml +5 -0
  5. obi_energy_tracker-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +22 -0
  6. obi_energy_tracker-0.1.0/.github/PULL_REQUEST_TEMPLATE.md +20 -0
  7. obi_energy_tracker-0.1.0/.github/dependabot.yml +15 -0
  8. obi_energy_tracker-0.1.0/.github/workflows/ci.yml +65 -0
  9. obi_energy_tracker-0.1.0/.github/workflows/release.yml +108 -0
  10. obi_energy_tracker-0.1.0/.gitignore +21 -0
  11. obi_energy_tracker-0.1.0/CHANGELOG.md +30 -0
  12. obi_energy_tracker-0.1.0/CODE_OF_CONDUCT.md +84 -0
  13. obi_energy_tracker-0.1.0/CONTRIBUTING.md +86 -0
  14. obi_energy_tracker-0.1.0/LICENSE +202 -0
  15. obi_energy_tracker-0.1.0/NOTICE +5 -0
  16. obi_energy_tracker-0.1.0/PKG-INFO +196 -0
  17. obi_energy_tracker-0.1.0/README.md +165 -0
  18. obi_energy_tracker-0.1.0/SECURITY.md +27 -0
  19. obi_energy_tracker-0.1.0/pyproject.toml +69 -0
  20. obi_energy_tracker-0.1.0/src/obi_energy_tracker/__init__.py +61 -0
  21. obi_energy_tracker-0.1.0/src/obi_energy_tracker/aggregation.py +77 -0
  22. obi_energy_tracker-0.1.0/src/obi_energy_tracker/auth.py +26 -0
  23. obi_energy_tracker-0.1.0/src/obi_energy_tracker/client.py +202 -0
  24. obi_energy_tracker-0.1.0/src/obi_energy_tracker/const.py +63 -0
  25. obi_energy_tracker-0.1.0/src/obi_energy_tracker/exceptions.py +27 -0
  26. obi_energy_tracker-0.1.0/src/obi_energy_tracker/models.py +76 -0
  27. obi_energy_tracker-0.1.0/src/obi_energy_tracker/parser.py +193 -0
  28. obi_energy_tracker-0.1.0/src/obi_energy_tracker/py.typed +0 -0
  29. obi_energy_tracker-0.1.0/tests/__init__.py +0 -0
  30. obi_energy_tracker-0.1.0/tests/conftest.py +235 -0
  31. obi_energy_tracker-0.1.0/tests/test_aggregation.py +220 -0
  32. obi_energy_tracker-0.1.0/tests/test_auth.py +13 -0
  33. obi_energy_tracker-0.1.0/tests/test_client.py +873 -0
  34. obi_energy_tracker-0.1.0/tests/test_exceptions.py +16 -0
  35. obi_energy_tracker-0.1.0/tests/test_parser.py +61 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ * @FabiNaryOBI @clemenslermen-obi
@@ -0,0 +1,45 @@
1
+ name: Bug report
2
+ description: Something in the library does not behave as documented
3
+ labels: [bug]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ This is the API client. If the problem is with entities, the config flow or the
9
+ Energy dashboard, report it in the OBI ENERGY TRACKER Home Assistant integration
10
+ repository instead.
11
+ - type: input
12
+ id: version
13
+ attributes:
14
+ label: Library version
15
+ placeholder: 0.1.0
16
+ validations:
17
+ required: true
18
+ - type: input
19
+ id: python
20
+ attributes:
21
+ label: Python version
22
+ placeholder: "3.14.2"
23
+ validations:
24
+ required: true
25
+ - type: textarea
26
+ id: what-happened
27
+ attributes:
28
+ label: What happened
29
+ description: What did you expect, and what happened instead?
30
+ validations:
31
+ required: true
32
+ - type: textarea
33
+ id: reproduce
34
+ attributes:
35
+ label: How to reproduce
36
+ description: The smallest snippet that shows the problem.
37
+ render: python
38
+ validations:
39
+ required: true
40
+ - type: textarea
41
+ id: logs
42
+ attributes:
43
+ label: Traceback or log output
44
+ description: Remove access tokens and personal data before pasting.
45
+ render: shell
@@ -0,0 +1,5 @@
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Security vulnerability
4
+ url: https://github.com/OBI-Smart-Technologies/energy-tracker-api-client-python/security/advisories/new
5
+ about: Report privately, never as a public issue
@@ -0,0 +1,22 @@
1
+ name: Feature request
2
+ description: Ask for a new endpoint, model field or helper
3
+ labels: [enhancement]
4
+ body:
5
+ - type: textarea
6
+ id: problem
7
+ attributes:
8
+ label: What are you trying to do?
9
+ description: The use case, not the implementation.
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: proposal
14
+ attributes:
15
+ label: Proposed API
16
+ description: How would you like to call it?
17
+ render: python
18
+ - type: textarea
19
+ id: context
20
+ attributes:
21
+ label: Anything else
22
+ description: Endpoint documentation, payload examples, related issues.
@@ -0,0 +1,20 @@
1
+ ## What does this change?
2
+
3
+ <!-- One or two sentences, and the issue it closes. -->
4
+
5
+ ## Type of change
6
+
7
+ - [ ] Bug fix (no public API change)
8
+ - [ ] New feature (new public name, backwards compatible)
9
+ - [ ] Breaking change (public name removed, renamed or behaving differently)
10
+ - [ ] Documentation or tooling only
11
+
12
+ ## Checklist
13
+
14
+ - [ ] `pytest` passes
15
+ - [ ] `ruff check src tests` and `ruff format --check src tests` pass
16
+ - [ ] `mypy` passes
17
+ - [ ] Tests cover the change
18
+ - [ ] No `homeassistant` import was added
19
+ - [ ] `CHANGELOG.md` has an entry under `## Unreleased`
20
+ - [ ] The public surface in `__init__.py` (`__all__`) is up to date
@@ -0,0 +1,15 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ open-pull-requests-limit: 5
8
+ groups:
9
+ dev-dependencies:
10
+ patterns:
11
+ - "*"
12
+ - package-ecosystem: github-actions
13
+ directory: /
14
+ schedule:
15
+ interval: weekly
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ lint:
18
+ name: Lint and type check
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+ - uses: actions/setup-python@v7
23
+ with:
24
+ python-version: "3.14"
25
+ cache: pip
26
+ - run: pip install -e ".[dev]"
27
+ - name: ruff check
28
+ run: ruff check src tests
29
+ - name: ruff format
30
+ run: ruff format --check src tests
31
+ - name: mypy
32
+ run: mypy
33
+
34
+ test:
35
+ name: Test on Python ${{ matrix.python-version }}
36
+ runs-on: ubuntu-latest
37
+ strategy:
38
+ fail-fast: false
39
+ matrix:
40
+ python-version: ["3.13", "3.14"]
41
+ steps:
42
+ - uses: actions/checkout@v7
43
+ - uses: actions/setup-python@v7
44
+ with:
45
+ python-version: ${{ matrix.python-version }}
46
+ cache: pip
47
+ - run: pip install -e ".[dev]"
48
+ - run: pytest --cov --cov-report=term-missing
49
+
50
+ build:
51
+ name: Build distribution
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - uses: actions/checkout@v7
55
+ - uses: actions/setup-python@v7
56
+ with:
57
+ python-version: "3.14"
58
+ - run: pip install build twine
59
+ - run: python -m build
60
+ - name: Check metadata
61
+ run: twine check --strict dist/*
62
+ - uses: actions/upload-artifact@v7
63
+ with:
64
+ name: dist
65
+ path: dist/
@@ -0,0 +1,108 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ index:
7
+ description: Package index to publish to
8
+ type: choice
9
+ options:
10
+ - pypi
11
+ - testpypi
12
+ default: pypi
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ build:
19
+ name: Build distribution
20
+ runs-on: ubuntu-latest
21
+ outputs:
22
+ version: ${{ steps.version.outputs.version }}
23
+ steps:
24
+ - uses: actions/checkout@v7
25
+ with:
26
+ fetch-depth: 0
27
+ - name: Refuse to release from anything but main
28
+ run: |
29
+ if [ "${{ github.ref }}" != "refs/heads/main" ]; then
30
+ echo "::error::Releases are cut from main only, not from ${{ github.ref }}."
31
+ exit 1
32
+ fi
33
+ - name: Refuse to release without a license
34
+ run: |
35
+ if [ ! -f LICENSE ]; then
36
+ echo "::error::No LICENSE file. An unlicensed package on PyPI may not legally be used by anyone — add the license before releasing."
37
+ exit 1
38
+ fi
39
+ - uses: actions/setup-python@v7
40
+ with:
41
+ python-version: "3.14"
42
+ - name: Read version from pyproject.toml
43
+ id: version
44
+ run: |
45
+ version="$(python -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
46
+ echo "version=$version" >> "$GITHUB_OUTPUT"
47
+ echo "Releasing version $version"
48
+ - name: Refuse to release an already tagged version
49
+ if: inputs.index == 'pypi'
50
+ run: |
51
+ if git rev-parse -q --verify "refs/tags/v${{ steps.version.outputs.version }}" >/dev/null; then
52
+ echo "::error::Tag v${{ steps.version.outputs.version }} already exists. Bump the version in pyproject.toml first."
53
+ exit 1
54
+ fi
55
+ - run: pip install build twine
56
+ - run: python -m build
57
+ - name: Check metadata
58
+ run: twine check --strict dist/*
59
+ - uses: actions/upload-artifact@v7
60
+ with:
61
+ name: dist
62
+ path: dist/
63
+
64
+ publish:
65
+ name: Publish to ${{ inputs.index }}
66
+ needs: build
67
+ runs-on: ubuntu-latest
68
+ environment:
69
+ name: ${{ inputs.index }}
70
+ url: ${{ inputs.index == 'pypi' && 'https://pypi.org/project/obi-energy-tracker/' || 'https://test.pypi.org/project/obi-energy-tracker/' }}${{ needs.build.outputs.version }}
71
+ permissions:
72
+ id-token: write
73
+ steps:
74
+ - uses: actions/download-artifact@v8
75
+ with:
76
+ name: dist
77
+ path: dist/
78
+ - name: Publish to TestPyPI
79
+ if: inputs.index == 'testpypi'
80
+ uses: pypa/gh-action-pypi-publish@release/v1
81
+ with:
82
+ repository-url: https://test.pypi.org/legacy/
83
+ - name: Publish to PyPI
84
+ if: inputs.index == 'pypi'
85
+ uses: pypa/gh-action-pypi-publish@release/v1
86
+
87
+ tag:
88
+ name: Tag and create GitHub release
89
+ needs: [build, publish]
90
+ if: inputs.index == 'pypi'
91
+ runs-on: ubuntu-latest
92
+ permissions:
93
+ contents: write
94
+ steps:
95
+ - uses: actions/checkout@v7
96
+ - uses: actions/download-artifact@v8
97
+ with:
98
+ name: dist
99
+ path: dist/
100
+ - name: Create tag and release
101
+ env:
102
+ GH_TOKEN: ${{ github.token }}
103
+ VERSION: ${{ needs.build.outputs.version }}
104
+ run: |
105
+ gh release create "v$VERSION" dist/* \
106
+ --title "v$VERSION" \
107
+ --generate-notes \
108
+ --target "$GITHUB_SHA"
@@ -0,0 +1,21 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ coverage.xml
13
+ htmlcov/
14
+
15
+ .idea/
16
+ *.iml
17
+ .vscode/
18
+ .DS_Store
19
+
20
+ AGENTS.md
21
+ CLAUDE.md
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
6
+ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## Unreleased
9
+
10
+ ## 0.1.0 - 2026-09-23
11
+
12
+ First public release. Extracted from the OBI ENERGY TRACKER Home Assistant integration,
13
+ where this code ran as an in-tree module.
14
+
15
+ ### Added
16
+
17
+ - `ObiEnergyTrackerApi` — async REST client for the OBI ENERGY TRACKER cloud with one
18
+ method per endpoint: `async_get_bridges`, `async_get_bridge_measures`,
19
+ `async_get_meter_readings`, `async_set_outlet_state`,
20
+ `async_get_bridge_firmware_update`, `async_trigger_bridge_firmware_update`.
21
+ `async_get_bridge_measures` covers both windows: with a `duration` the recent values of
22
+ every device, without one their complete history in a single request.
23
+ - `Bridge`, `Device`, `MeasureRecord` and `FirmwareUpdate` payload models, plus the
24
+ `DeviceKind`, `OutletState`, `Measure`, `OtaStatus` and `ConnectionStrength` enums.
25
+ - `ObiEnergyTrackerError` with the `ObiEnergyTrackerAuthError` and
26
+ `ObiEnergyTrackerDeviceOfflineError` subclasses.
27
+ - `TokenProvider` and `static_token_provider` — authentication is awaited before every
28
+ request, so the caller keeps ownership of the OAuth2 session.
29
+ - `connection_strength_from_rssi` — the backend's own RSSI thresholds.
30
+ - `hourly_buckets` and `cumulative` — hourly and cumulative consumption.
@@ -0,0 +1,84 @@
1
+
2
+ # Contributor Covenant Code of Conduct
3
+
4
+ ## Our Pledge
5
+
6
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
7
+
8
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
9
+
10
+ ## Our Standards
11
+
12
+ Examples of behavior that contributes to a positive environment for our community include:
13
+
14
+ * Demonstrating empathy and kindness toward other people
15
+ * Being respectful of differing opinions, viewpoints, and experiences
16
+ * Giving and gracefully accepting constructive feedback
17
+ * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
18
+ * Focusing on what is best not just for us as individuals, but for the overall community
19
+
20
+ Examples of unacceptable behavior include:
21
+
22
+ * The use of sexualized language or imagery, and sexual attention or advances of any kind
23
+ * Trolling, insulting or derogatory comments, and personal or political attacks
24
+ * Public or private harassment
25
+ * Publishing others' private information, such as a physical or email address, without their explicit permission
26
+ * Other conduct which could reasonably be considered inappropriate in a professional setting
27
+
28
+ ## Enforcement Responsibilities
29
+
30
+ Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
31
+
32
+ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
33
+
34
+ ## Scope
35
+
36
+ This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
37
+
38
+ ## Enforcement
39
+
40
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement, listed in [CODEOWNERS](.github/CODEOWNERS). All complaints will be reviewed and investigated promptly and fairly.
41
+
42
+ All community leaders are obligated to respect the privacy and security of the reporter of any incident.
43
+
44
+ ## Enforcement Guidelines
45
+
46
+ Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
47
+
48
+ ### 1. Correction
49
+
50
+ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
51
+
52
+ **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
53
+
54
+ ### 2. Warning
55
+
56
+ **Community Impact**: A violation through a single incident or series of actions.
57
+
58
+ **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
59
+
60
+ ### 3. Temporary Ban
61
+
62
+ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
63
+
64
+ **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
65
+
66
+ ### 4. Permanent Ban
67
+
68
+ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
69
+
70
+ **Consequence**: A permanent ban from any sort of public interaction within the community.
71
+
72
+ ## Attribution
73
+
74
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
75
+
76
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
77
+
78
+ For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
79
+
80
+ [homepage]: https://www.contributor-covenant.org
81
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
82
+ [Mozilla CoC]: https://github.com/mozilla/diversity
83
+ [FAQ]: https://www.contributor-covenant.org/faq
84
+ [translations]: https://www.contributor-covenant.org/translations
@@ -0,0 +1,86 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time to contribute. This library is the API layer of the
4
+ [OBI ENERGY TRACKER Home Assistant integration](https://github.com/FabiNaryOBI/ha-obi-energy-tracker),
5
+ so a change here can reach every Home Assistant user of the integration.
6
+
7
+ By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
8
+
9
+ ## Development setup
10
+
11
+ ```bash
12
+ python3.14 -m venv .venv
13
+ .venv/bin/pip install -e ".[dev]"
14
+ ```
15
+
16
+ ## Checks
17
+
18
+ All four have to pass before a pull request can be merged; CI runs exactly these:
19
+
20
+ ```bash
21
+ .venv/bin/python -m pytest
22
+ .venv/bin/ruff check src tests
23
+ .venv/bin/ruff format --check src tests
24
+ .venv/bin/mypy
25
+ ```
26
+
27
+ Tests also run against the oldest supported Python (3.13) in CI.
28
+
29
+ ## Ground rules
30
+
31
+ - **No Home Assistant imports.** The library must stay usable without Home Assistant.
32
+ Anything that needs `homeassistant` belongs in the integration repository.
33
+ - **Async only.** No blocking I/O, no `requests`, no thread pools. The caller supplies the
34
+ `aiohttp.ClientSession`; the library never creates or closes one.
35
+ - **Fully typed.** `mypy --strict` is clean and `py.typed` is shipped, so consumers get
36
+ the types. New public functions need annotations, not `Any`.
37
+ - **Unknown values do not escape.** A `state`, `measure` or
38
+ `otaStatus` the library does not know parses to `None` or is dropped. Never pass a raw
39
+ backend string to the caller.
40
+ - **Every request carries an explicit timeout.** A shared session's default is far too
41
+ coarse to rely on.
42
+ - **Tests need no network.** `tests/conftest.py` starts a real local `aiohttp` server
43
+ (`FakeBackend`); queue responses with `backend.respond(...)` and assert on
44
+ `backend.requests`. Do not add an HTTP mocking dependency — `aioresponses` breaks
45
+ whenever `aiohttp` changes its internals.
46
+ - **Keep the public surface small.** `__init__.py`'s `__all__` is the contract; everything
47
+ else is an implementation detail and may change in a patch release.
48
+
49
+ ## Pull requests
50
+
51
+ 1. Branch off `main` (`feat/…`, `fix/…`, `docs/…`).
52
+ 2. Keep the change focused, and add tests for it.
53
+ 3. Add an entry under `## Unreleased` in [CHANGELOG.md](CHANGELOG.md).
54
+ 4. Open the pull request and fill in the template.
55
+
56
+ ## Versioning
57
+
58
+ [Semantic Versioning](https://semver.org/). The Home Assistant integration pins this
59
+ package exactly (`obi-energy-tracker==X.Y.Z` in its `manifest.json`), so any change to the
60
+ public surface has to be reflected in the version number:
61
+
62
+ - **major** — a removed or renamed public name, a changed signature, changed parsing
63
+ semantics
64
+ - **minor** — new endpoints, new public helpers, new model fields
65
+ - **patch** — bug fixes and internals
66
+
67
+ ## Releasing
68
+
69
+ Releases are cut manually by the repository owners; nothing is published on a push or a
70
+ merge.
71
+
72
+ The repository is licensed under the Apache License, Version 2.0; the copyright holder is
73
+ OBI Smart Technologies GmbH. Contributions are accepted under the same license (see
74
+ section 5 of the license). Do not remove `LICENSE` or `NOTICE` — the release workflow
75
+ refuses to run without a `LICENSE` file, and both files are shipped in the distribution
76
+ via `license-files` in `pyproject.toml`.
77
+
78
+ 1. Bump `version` in `pyproject.toml` and rename the `## Unreleased` heading in
79
+ `CHANGELOG.md` to `## X.Y.Z - YYYY-MM-DD`, leaving a fresh empty `## Unreleased` above
80
+ it.
81
+ 2. Merge that to `main`.
82
+ 3. Run the **Release** workflow (Actions → Release → *Run workflow*) with `index=testpypi`
83
+ first if the packaging changed, then with `index=pypi`. The workflow only runs on `main`.
84
+ 4. The workflow builds the distribution, refuses to continue if `vX.Y.Z` is already
85
+ tagged, waits for the `pypi` environment approval, publishes, and then creates the tag
86
+ and the GitHub release.