diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 00000000..48f24d28 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,27 @@ +Cut a release candidate or stable release. + +Usage: +- `/release rc` — tag the current `develop` HEAD as the next RC (e.g. `0.13.0-rc2`) +- `/release stable` — merge `develop` into `main` and tag the final release + +## RC release (`/release rc`) + +1. Confirm the current branch is `develop` and is up to date with `origin/develop`. +2. Read CHANGELOG.md to find the version being prepared (the top-most `## X.Y.Z` entry). +3. List existing tags to determine the next RC number (e.g. if `0.13.0-rc1` exists, use `rc2`). +4. Run `poetry run pytest tests` and check that CI on `develop` HEAD is green (`gh run list --branch develop --limit 3`) — do not tag if either fails. +5. Tag `develop` HEAD as `-rc` and push the tag. +6. Create the GitHub pre-release with generated notes: + `gh release create --verify-tag --prerelease --title --generate-notes --notes-start-tag ` +7. Report the tag name, the release URL and the `git log --oneline ..HEAD` summary. Mention that the "build dev images" run was started by the tag. + +## Stable release (`/release stable`) + +1. Confirm CHANGELOG.md has a section for the version being released. +2. Open a PR from `develop` → `main` titled `chore: release `. +3. After the user confirms the PR is merged, tag `main` HEAD as `` and push. +4. Create the GitHub release with generated notes, starting from the previous stable tag (not an RC) so the notes cover the whole release: + `gh release create --verify-tag --latest --title --generate-notes --notes-start-tag ` +5. Report the tag and the GitHub release URL. + +**RC tags always go on `develop`. Stable tags always go on `main`.** diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..5b5e2614 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(poetry run pytest:*)", + "Bash(poetry run mypy:*)", + "Bash(poetry run ruff check:*)", + "Bash(poetry run ruff format:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)" + ], + "deny": [ + "Read(./.env)", + "Read(./.env.*)" + ] + } +} diff --git a/.dockerignore b/.dockerignore index 637ab312..c3d2503f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -187,4 +187,9 @@ fabric.properties # Test results junit/ -.github/ \ No newline at end of file +.github/ + +# Not needed to build the image +.git/ +.claude/ +tests/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..edd2def2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + target-branch: develop + schedule: + interval: weekly + - package-ecosystem: pip + directory: / + target-branch: develop + schedule: + interval: weekly + groups: + dev-dependencies: + dependency-type: development + - package-ecosystem: docker + directory: / + target-branch: develop + schedule: + interval: weekly diff --git a/.github/workflows/build_and_test_python.yml b/.github/workflows/build_and_test_python.yml index 01f7bd00..fa57ad2f 100644 --- a/.github/workflows/build_and_test_python.yml +++ b/.github/workflows/build_and_test_python.yml @@ -18,15 +18,16 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [ "3.12", "3.13", "3.14" ] + python-version: [ "3.12", "3.13", "3.14", "3.15" ] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -38,10 +39,10 @@ jobs: - name: Load cached venv id: cached-poetry-dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .venv - key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} + key: venv-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' diff --git a/.github/workflows/build_docker_images.yml b/.github/workflows/build_docker_images.yml index 1a14fab7..b7b87f73 100644 --- a/.github/workflows/build_docker_images.yml +++ b/.github/workflows/build_docker_images.yml @@ -31,16 +31,20 @@ env: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - platform: - - linux/amd64 - - linux/arm64 - - linux/arm/v7 + include: + # Native Arm runners build both Arm platforms without QEMU + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + - platform: linux/arm/v7 + runner: ubuntu-24.04-arm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set platform slug id: platform @@ -50,9 +54,6 @@ jobs: id: repo run: echo "lowercase=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/.gitignore b/.gitignore index ad6580a6..74ea6dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -189,4 +189,7 @@ junit/ .run/ # macOS -.DS_Store \ No newline at end of file +.DS_Store +# Claude Code local state (commands/ is tracked) +.claude/settings.local.json +.claude/worktrees/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d62febda..1ac09ae0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,12 +9,12 @@ default_language_version: repos: - repo: https://github.com/python-poetry/poetry - rev: '2.1.3' + rev: '2.5.1' hooks: - id: poetry-check - id: poetry-lock - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.0 + rev: v0.17.1 hooks: - id: ruff args: @@ -38,7 +38,7 @@ repos: pass_filenames: false stages: [pre-push] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-yaml - id: check-added-large-files diff --git a/CHANGELOG.md b/CHANGELOG.md index 57439d11..0110133c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,105 @@ # Change Log +## 0.13.0 + +### Added + +* Publish `drivetrain/chargingDataAvailable` (`online`/`offline`). The Battery + Charging sensor, Remaining charging time and Charging finished entities go + unavailable in Home Assistant after 3 consecutive failed charge status + requests, for example when the car falls into deep sleep right after a + charge, instead of showing a stale value. The Charging switch stays + available (#471). + +* Document the observed `chargingType` values and the availability topics in + the README (#472, #476). + +### Changed + +* Replace gmqtt with aiomqtt for MQTT connectivity. The new client uses a + pure-asyncio architecture — no more `run_coroutine_threadsafe` cross-thread + calls. Reconnect now uses exponential backoff (5 s → 10 s → … → 5 min) + instead of a fixed interval (#457). + +* Mark a vehicle `offline` only after 3 consecutive failed polls, so a single + "try again later" reply from the SAIC server no longer makes every entity + unavailable in Home Assistant (#475). + +* Require `saic-ismart-client-ng` 0.10.0 and close its HTTP client on shutdown + (#479). + +* Shut down gracefully on SIGTERM, so `docker stop` no longer ends in a + SIGKILL after 10 s (#481). + +* Upgrade development dependencies (ruff 0.17, mypy 2.4), bump the GitHub + Actions and pre-commit hooks, and add Dependabot (#480). + +* Build the Docker image on the Poetry 2.5.1 base image, build the Arm images + on native Arm runners instead of QEMU, and run the tests on Python 3.15 as a + non-blocking check (#495, #496). + +### Fixed + +* Exit immediately on permanent MQTT connection failures (bad credentials, + unknown protocol version, identifier rejected, not authorised) instead of + looping forever (#457). + +* Restore MQTT subscriptions and trigger HA re-discovery on broker reconnect + after a transient disconnect (#457). + +* Suppress spurious `WARNING: Could not extract a valid SoC kWh` log on every + poll for non-EV vehicles. The warning is preserved for EVs where the BMS + returns invalid data (#460). + +* Publish command result topics (`Success` / `Failed: …`) with `retain=False` + so a stale result from a previous session does not persist on the broker + across restarts (#461). + +* Emit `retain` as a JSON boolean (`true`/`false`) in Home Assistant MQTT + discovery payloads instead of the string `"true"`/`"false"` that the HA + schema requires (#459). + +* Do not publish the charging cable lock state for models that do not report + it, such as the MG ZS EV, instead of always publishing "unlocked". The + commanded state is published after a lock or unlock command (#477). + +* List what is missing when an ABRP update is skipped, instead of always + reporting a missing configuration (#470). + +* Log SAIC API timeouts and "try again later" replies as a short warning + without a traceback, and log failed commands on transient API errors at + ERROR level (#473, #474). + +* Fix the scheduled charging job failing with `no running event loop` since + the aiomqtt migration, which meant the gateway never switched to `force` + refresh at the scheduled charging start time (#484, #485). + +* Exit when the MQTT connection task ends, so Docker or Kubernetes can restart + the gateway instead of it running without MQTT. Running with MQTT disabled + is not affected (#491). + +* Do not hang at startup when the MQTT connection task crashes before the first + connection (#489), and log such crashes instead of leaving them unreported + (#488, #490). + +* Keep references to the tasks that publish to MQTT, so they cannot be + garbage collected before they finish, and report a clear error when + publishing from outside the event loop (#487, #488). + +* Wait up to 2 s for pending MQTT publishes on shutdown, including when the + shutdown happens during startup, instead of dropping them (#492). + +* Prefer the `RELEASE_VERSION` set at build time over the package metadata for + the reported gateway version (#493). + +* Install only the main dependency group in the Docker image (#469). + +* Warn when TLS hostname verification is disabled regardless of whether a + custom CA certificate is configured. Self-signed cert users (who typically + have no CA file) now see the warning too (#462). + +**Full Changelog**: https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/compare/0.12.0...0.13.0 + ## 0.12.0 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4ec7bd01..5b1d7a88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Always branch from `develop` for features and bugfixes.** PRs must target `develop`, not `main`. The only exception is a hotfix that must go directly to `main`. +## Release process + +1. Merge all relevant PRs into `develop`. +2. Add a `## ` section to `CHANGELOG.md` and commit it directly to `develop`. +3. Tag `develop` as `-rc` (e.g. `0.13.0-rc1`) and push the tag — **RC tags live on `develop`, not `main`**. +4. After the RC is validated, open a PR from `develop` → `main` and merge it. +5. Tag the resulting `main` commit as the final release (e.g. `0.13.0`) and push the tag. +6. Create a GitHub release for every tag, RC or final, with generated notes (`gh release create --verify-tag --generate-notes`). Add `--prerelease` for RCs and `--latest` for the final release. `/release` does this. + ## Commands ```bash diff --git a/Dockerfile b/Dockerfile index 5b8f3509..63eb3f5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG POETRY_VERSION=2.3.2 +ARG POETRY_VERSION=2.5.1 ARG PYTHON_VERSION=3.14 FROM nanomad/poetry:${POETRY_VERSION}-python-${PYTHON_VERSION} AS builder @@ -12,7 +12,7 @@ COPY pyproject.toml poetry.lock /usr/src/app/ # Install the dependencies and clear the cache afterwards. # This may save some MBs. -RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR +RUN poetry install --no-root --only main && rm -rf $POETRY_CACHE_DIR # Now let's build the runtime image from the builder. # We'll just copy the env and the PATH reference. diff --git a/README.md b/README.md index 17347cfb..06c2e236 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The following are the known available endpoints: |-----------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | --ha-discovery | HA_DISCOVERY_ENABLED | Home Assistant auto-discovery is enabled (True) by default. It can be disabled (False) with this parameter. | | --ha-discovery-prefix | HA_DISCOVERY_PREFIX | The default MQTT prefix for Home Assistant auto-discovery is 'homeassistant'. Another prefix can be configured with this parameter | -| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | +| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant after 3 consecutive failed polls (a single failed poll is tolerated). Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | ### A Better Route Planner (ABRP) integration @@ -229,6 +229,27 @@ with the default vehicle prefix: `saic//vehicles/` | /refresh/period/inActiveGrace/set | grace period (sec) | After the vehicle has been shutdown, the gateway continues to query the state for 600 seconds (default value). The duration of this extended query period can be modified with this topic. | | /location/findMyCar/set | [activate,lights_only,horn_only,stop] | Activate 'find my car' with lights and horn (activate), with lights only (lights_only), with horn only (horn_only) or deactivate it (stop). | +## Charging type values + +The `/drivetrain/chargingType` topic publishes the raw `chargingType` value reported by the SAIC API. The API does not +document it. The meanings below were reported by users in +[#398](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues/398) and checked against about 89k logged +API responses from one vehicle (Mar 2024 – Oct 2026). They may differ between models. + +| Value | Reported meaning | Checked against logged data | +|-------|----------------------------------------------------|----------------------------------------------------------------------------------| +| 0 | Car in deep sleep | Not supported: 31 isolated samples between values 1 and 3, not a sustained state | +| 1 | Car just parked | Consistent (near-zero battery current) | +| 3 | HV battery in use (driving, or remote A/C on) | Consistent (battery current under load) | +| 6 | AC charging | Confirmed (on-board charger input current present) | +| 7 | DC charging | Confirmed (about 200 A battery current, no AC input) | +| 9 | Charging stopped from the infotainment | Unclear: both samples follow DC charging while plugged in | +| 10 | Charging ended | Supported: 17 of 24 samples follow AC charging while plugged in | +| 16 | Car unplugged while charging was still in progress | Never observed | + +Values 2, 4 and 12 were seen only 1 to 3 times each. 4 and 12 appeared around the start or end of charging, which could +mean a transition state. Their meaning is unknown. + ## Home Assistant auto-discovery The gateway supports [Home Assistant MQTT discovery](https://www.home-assistant.io/integrations/mqtt#mqtt-discovery). It @@ -252,3 +273,17 @@ This device provides visibility into the gateway's status even when no vehicles ### Vehicle devices Each vehicle registered to your account appears as a separate device with sensors for drivetrain, climate, location, and more. + +#### Availability + +Two topics control when vehicle entities show as unavailable. Both publish `online` or `offline`, and both tolerate +short glitches: they switch to `offline` only after 3 consecutive failures, and back to `online` on the next success. + +| Topic | Goes `offline` when | Entities affected | +|------------------------------------------|------------------------------------------------------|-----------------------------------------------------------------------------------------| +| `/available` | 3 consecutive polls of the vehicle API fail | All vehicle entities (unless `HA_SHOW_UNAVAILABLE` is disabled) | +| `/drivetrain/chargingDataAvailable` | 3 consecutive charge status requests fail | Battery Charging, Remaining charging time and Charging finished | + +The charge status request is the one that fails when the car goes into deep sleep right after a charge. The vehicle stays +`online` in that case, so the two topics are separate. Other entities keep their last known value. The **Charging** +switch stays available, so you can still start or stop charging from Home Assistant while the car is asleep. diff --git a/poetry.lock b/poetry.lock index 04bd68a0..12190374 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,20 @@ # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +[[package]] +name = "aiomqtt" +version = "2.5.1" +description = "The idiomatic asyncio MQTT client" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "aiomqtt-2.5.1-py3-none-any.whl", hash = "sha256:fd58c3593160e4d475d90ce911cdfc4239cd64de96b0ba22edf6c86bd7afa278"}, + {file = "aiomqtt-2.5.1.tar.gz", hash = "sha256:25a0a47d157e8f158d2da1110ea4786c0615518751e94f7b04976c977a8ff20d"}, +] + +[package.dependencies] +paho-mqtt = ">=2.1.0,<3.0.0" + [[package]] name = "anyio" version = "4.12.1" @@ -47,16 +62,83 @@ tornado = ["tornado (>=4.3)"] twisted = ["twisted"] zookeeper = ["kazoo"] +[[package]] +name = "ast-serialize" +version = "0.13.1" +description = "Python bindings for mypy AST serialization" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3"}, + {file = "ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5"}, + {file = "ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489"}, + {file = "ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6"}, + {file = "ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a"}, + {file = "ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7"}, + {file = "ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43"}, + {file = "ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025"}, +] + [[package]] name = "astroid" -version = "4.0.4" +version = "4.3.4" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.10.0" groups = ["dev"] files = [ - {file = "astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753"}, - {file = "astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0"}, + {file = "astroid-4.3.4-py3-none-any.whl", hash = "sha256:2bcd0d02648a443a4b818c952c3550091989daefac3c12d3b83b2289482e0818"}, + {file = "astroid-4.3.4.tar.gz", hash = "sha256:d515a105722b72098bbe82d430d65e635f742b6cbac3bdfaf8b7c188b87c5e39"}, ] [[package]] @@ -98,118 +180,133 @@ files = [ [[package]] name = "coverage" -version = "7.13.4" +version = "7.16.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415"}, - {file = "coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9"}, - {file = "coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf"}, - {file = "coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9"}, - {file = "coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9"}, - {file = "coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f"}, - {file = "coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0"}, - {file = "coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246"}, - {file = "coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126"}, - {file = "coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a"}, - {file = "coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d"}, - {file = "coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd"}, - {file = "coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b"}, - {file = "coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9"}, - {file = "coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd"}, - {file = "coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, - {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, - {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, - {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, - {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, - {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, - {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, - {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, - {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, + {file = "coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b"}, + {file = "coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72"}, + {file = "coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5"}, + {file = "coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943"}, + {file = "coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7"}, + {file = "coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e"}, + {file = "coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d"}, + {file = "coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a"}, + {file = "coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035"}, + {file = "coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878"}, + {file = "coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516"}, + {file = "coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d"}, + {file = "coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a"}, + {file = "coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48"}, + {file = "coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800"}, + {file = "coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd"}, + {file = "coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206"}, + {file = "coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a"}, + {file = "coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162"}, + {file = "coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02"}, + {file = "coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9"}, + {file = "coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541"}, + {file = "coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8"}, + {file = "coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4"}, + {file = "coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865"}, + {file = "coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6"}, + {file = "coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79"}, + {file = "coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a"}, + {file = "coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c"}, + {file = "coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8"}, + {file = "coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6"}, + {file = "coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011"}, + {file = "coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b"}, + {file = "coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb"}, + {file = "coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf"}, + {file = "coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681"}, + {file = "coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20"}, + {file = "coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce"}, + {file = "coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46"}, + {file = "coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c"}, + {file = "coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91"}, + {file = "coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8"}, + {file = "coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982"}, + {file = "coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a"}, + {file = "coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63"}, + {file = "coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e"}, + {file = "coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae"}, + {file = "coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495"}, + {file = "coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75"}, + {file = "coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f"}, + {file = "coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5"}, + {file = "coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c"}, + {file = "coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35"}, + {file = "coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5"}, + {file = "coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8"}, + {file = "coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f"}, + {file = "coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa"}, ] [package.extras] @@ -270,21 +367,6 @@ files = [ {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, ] -[[package]] -name = "gmqtt" -version = "0.7.0" -description = "Client for MQTT protocol" -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "gmqtt-0.7.0-py3-none-any.whl", hash = "sha256:3e5571a20e9c115d83d600caa228b06f716087653e241035e29cec73277b52cc"}, - {file = "gmqtt-0.7.0.tar.gz", hash = "sha256:bedfec7bac26b6b4ce1f0c4c32cff3d663526a54c882d323d41560fc3b9b44a2"}, -] - -[package.extras] -test = ["atomicwrites (>=1.3.0)", "attrs (>=19.1.0)", "codecov (>=2.0.15)", "coverage (>=4.5.3)", "more-itertools (>=7.0.0)", "pluggy (>=0.11.0)", "py (>=1.8.0)", "pytest (>=5.4.0)", "pytest-asyncio (>=0.12.0)", "pytest-cov (>=2.7.1)", "six (>=1.12.0)", "uvloop (>=0.14.0)"] - [[package]] name = "h11" version = "0.16.0" @@ -416,103 +498,150 @@ plugins = ["setuptools"] [[package]] name = "librt" -version = "0.8.1" +version = "0.16.0" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ - {file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"}, - {file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"}, - {file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"}, - {file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"}, - {file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"}, - {file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"}, - {file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"}, - {file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"}, - {file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"}, - {file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"}, - {file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"}, - {file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"}, - {file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"}, - {file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"}, - {file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"}, - {file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"}, - {file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"}, - {file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"}, - {file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"}, - {file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"}, - {file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"}, - {file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"}, - {file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"}, - {file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"}, - {file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"}, - {file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"}, - {file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"}, - {file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"}, - {file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"}, - {file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"}, - {file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"}, - {file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"}, - {file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"}, - {file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"}, - {file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"}, - {file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"}, - {file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"}, - {file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"}, - {file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"}, - {file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"}, - {file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"}, - {file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"}, - {file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"}, - {file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"}, - {file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"}, - {file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"}, - {file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"}, - {file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"}, - {file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"}, - {file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"}, - {file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"}, - {file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"}, - {file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"}, - {file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"}, - {file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"}, - {file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"}, - {file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"}, - {file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"}, + {file = "librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6"}, + {file = "librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262"}, + {file = "librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143"}, + {file = "librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba"}, + {file = "librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155"}, + {file = "librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18"}, + {file = "librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602"}, + {file = "librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96"}, + {file = "librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7"}, + {file = "librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d"}, + {file = "librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678"}, + {file = "librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742"}, + {file = "librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07"}, + {file = "librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b"}, + {file = "librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a"}, + {file = "librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940"}, + {file = "librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc"}, + {file = "librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a"}, + {file = "librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6"}, + {file = "librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e"}, + {file = "librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d"}, + {file = "librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4"}, + {file = "librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92"}, + {file = "librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91"}, + {file = "librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1"}, + {file = "librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667"}, + {file = "librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152"}, + {file = "librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839"}, + {file = "librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7"}, + {file = "librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41"}, + {file = "librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99"}, + {file = "librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc"}, + {file = "librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1"}, + {file = "librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7"}, + {file = "librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d"}, + {file = "librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b"}, + {file = "librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff"}, + {file = "librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d"}, + {file = "librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42"}, + {file = "librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a"}, + {file = "librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3"}, + {file = "librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971"}, + {file = "librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560"}, + {file = "librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b"}, + {file = "librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba"}, + {file = "librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9"}, + {file = "librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc"}, + {file = "librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866"}, + {file = "librt-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e1967e36ac4cae0c7e9615ad32e1a513cdacff79f9e8afb28bedc91caf48b4f3"}, + {file = "librt-0.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:20fe0bf9053885e21c62b3e091fb5e73e1c54d1daf2e70eb388d70763bcd4220"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a63610fa76524edfa605b5b259a603915c7a6e10e54f003e5503030506e81de"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:845a511b60ca43b9880dcc84a9784c891d6a2098c829130b320846c69c9c0c68"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d608f0bf3b8cbddd0067fe02cb8cab7d13e8eb9386b23a1843d4363044fd9e22"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df183721229ae51eef90108c115b43e98cb169b6155d34480338e5fc6616df00"}, + {file = "librt-0.16.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b6183e2e2e0ee00aac2ec07c7f7d151c97e85666b304f574b71cff0f9fccc4e"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:273d00be33792a15189331df10f1f1331621b043881e66c6c4377f7f776e1291"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aa9357a1b4d4fc787bb718a59cb1112c28c8a976d6bfa268b71cc0a4ab8f3a94"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e9ce0bc440e7fd09b5f51f372f0f6640658f854b8fb05260f819cc669c93c42d"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:6c8893eae2fd13c5488d94056f3e6e5cf3142bfb1c4acaf136cb33d760c5964b"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7393c9a48dcce4817dbd4b0d8ff6237efe9b0a0609f5b0adaef315f8541726b5"}, + {file = "librt-0.16.0-cp39-cp39-win32.whl", hash = "sha256:71b93b42784e25b975079573c642a8fedb049a7bb31d70a51721b1666b3b2ced"}, + {file = "librt-0.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:5750a105b42a416f930edc59054927a406effb2550cd5bab92ad7a5842ed5d05"}, + {file = "librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc"}, ] [[package]] @@ -546,57 +675,75 @@ test = ["pytest", "pytest-cov"] [[package]] name = "mypy" -version = "1.19.1" +version = "2.4.0" description = "Optional static typing for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, - {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, - {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, - {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, - {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, - {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, - {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, - {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, - {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, - {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, - {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, - {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, - {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, - {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, - {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, - {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, + {file = "mypy-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffda5244fd1ad71a1e54405e35f50d09b80c3978efa120f58bd1252ae32c62d2"}, + {file = "mypy-2.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:615b03922d40e186fd1df73156473db0ba525c639bb4fd1cf28e1694879c9b23"}, + {file = "mypy-2.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13fa24f439c0e48a290a3922fa14ccd22f2762bae99d2142931b3e40a9055080"}, + {file = "mypy-2.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3adef556a19eb3b630bf86a79c29d7da3d61e541472d0d897ca01b171c0abf8f"}, + {file = "mypy-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:1dc0f64b0a92ae27a49d2175f0bacfa56e15bdc5b420cf92e0c1f79292219cbb"}, + {file = "mypy-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a378fc15fb33e321f04652c166ce73eeb8833a97c3d218132844e938cd93220"}, + {file = "mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd"}, + {file = "mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74"}, + {file = "mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1"}, + {file = "mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4"}, + {file = "mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b"}, + {file = "mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd"}, + {file = "mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e"}, + {file = "mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90"}, + {file = "mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e"}, + {file = "mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1"}, + {file = "mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946"}, + {file = "mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989"}, + {file = "mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8"}, + {file = "mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324"}, + {file = "mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb"}, + {file = "mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1"}, + {file = "mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf"}, + {file = "mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303"}, + {file = "mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437"}, + {file = "mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e"}, + {file = "mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b"}, + {file = "mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566"}, + {file = "mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe"}, + {file = "mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89"}, + {file = "mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799"}, + {file = "mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97"}, + {file = "mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359"}, + {file = "mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3"}, + {file = "mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c"}, + {file = "mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76"}, + {file = "mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37"}, + {file = "mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a"}, + {file = "mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d"}, + {file = "mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818"}, + {file = "mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5"}, + {file = "mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf"}, + {file = "mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34"}, + {file = "mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3"}, + {file = "mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01"}, + {file = "mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141"}, + {file = "mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0"}, + {file = "mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975"}, + {file = "mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710"}, + {file = "mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5"}, + {file = "mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093"}, + {file = "mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e"}, ] [package.dependencies] -librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +ast-serialize = ">=0.11.2,<1.0.0" +librt = {version = ">=0.16.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" -typing_extensions = ">=4.6.0" +pathspec = ">=1.0.0" +typing_extensions = [ + {version = ">=4.6.0", markers = "python_version < \"3.15\""}, + {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, +] [package.extras] dmypy = ["psutil (>=4.0)"] @@ -641,6 +788,21 @@ files = [ {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +description = "MQTT version 5.0/3.1.1 client class" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, + {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, +] + +[package.extras] +proxy = ["pysocks"] + [[package]] name = "pathspec" version = "1.0.4" @@ -689,14 +851,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "4.6.0" +version = "4.7.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false -python-versions = ">=3.10" +python-versions = ">=3.11" groups = ["dev"] files = [ - {file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"}, - {file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"}, + {file = "pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877"}, + {file = "pre_commit-4.7.0.tar.gz", hash = "sha256:1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86"}, ] [package.dependencies] @@ -774,21 +936,21 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pylint" -version = "4.0.5" +version = "4.1.3" description = "python code static checker" optional = false python-versions = ">=3.10.0" groups = ["dev"] files = [ - {file = "pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2"}, - {file = "pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c"}, + {file = "pylint-4.1.3-py3-none-any.whl", hash = "sha256:a85357cae24f33ad8d86c8f3daaa92c600ae4012b54a57299cee76000e9364cf"}, + {file = "pylint-4.1.3.tar.gz", hash = "sha256:9928603068edfa0d1a3c167f174b099d4b97c3db75d32d0fcdd029770b4713a9"}, ] [package.dependencies] -astroid = ">=4.0.2,<=4.1.dev0" +astroid = ">=4.3.4,<=4.4" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} -isort = ">=5,<5.13 || >5.13,<9" +isort = ">=5,!=5.13,<10" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2" tomlkit = ">=0.10.1" @@ -799,14 +961,14 @@ testutils = ["gitpython (>3)"] [[package]] name = "pytest" -version = "9.0.2" +version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, - {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [package.dependencies] @@ -821,34 +983,34 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, - {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, + {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, + {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, ] [package.dependencies] -pytest = ">=8.2,<10" +pytest = ">=8.4,<10" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, - {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, ] [package.dependencies] @@ -861,14 +1023,14 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-mock" -version = "3.15.1" +version = "3.16.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, - {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, + {file = "pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8"}, + {file = "pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636"}, ] [package.dependencies] @@ -997,42 +1159,42 @@ files = [ [[package]] name = "ruff" -version = "0.15.5" +version = "0.17.1" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c"}, - {file = "ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080"}, - {file = "ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010"}, - {file = "ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65"}, - {file = "ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440"}, - {file = "ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204"}, - {file = "ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8"}, - {file = "ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681"}, - {file = "ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a"}, - {file = "ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca"}, - {file = "ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd"}, - {file = "ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d"}, - {file = "ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752"}, - {file = "ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2"}, - {file = "ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74"}, - {file = "ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe"}, - {file = "ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b"}, - {file = "ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2"}, + {file = "ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a"}, + {file = "ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb"}, + {file = "ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74"}, + {file = "ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978"}, + {file = "ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640"}, + {file = "ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1"}, + {file = "ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05"}, + {file = "ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785"}, + {file = "ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189"}, + {file = "ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467"}, + {file = "ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab"}, + {file = "ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca"}, + {file = "ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4"}, + {file = "ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a"}, + {file = "ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a"}, + {file = "ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7"}, + {file = "ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4"}, + {file = "ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f"}, ] [[package]] name = "saic-ismart-client-ng" -version = "0.9.3" +version = "0.10.0" description = "SAIC next gen client library (MG iSMART)" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "saic_ismart_client_ng-0.9.3-py3-none-any.whl", hash = "sha256:8737a5879263cbfca81dc4147e7d6c5388732a565d0b11cf1cd79be3bfe4d248"}, - {file = "saic_ismart_client_ng-0.9.3.tar.gz", hash = "sha256:5d0f7af2df7275b33f8995c1d5e90e0a52e99961f9860450bae9bbfac73019ad"}, + {file = "saic_ismart_client_ng-0.10.0-py3-none-any.whl", hash = "sha256:4cbe793c5fcbdaf6880cf865fcaa0f03154cd5c91b4bb38a902c2d193f94c82c"}, + {file = "saic_ismart_client_ng-0.10.0.tar.gz", hash = "sha256:d4517c6c1e979471f5631b0836665f02352bb699c55309dff50a03df15077709"}, ] [package.dependencies] @@ -1134,4 +1296,4 @@ python-discovery = ">=1.2.2" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "ba4f444f330a6cb58a06926a1c2701c544ffeaeda02107641361b7abaa61e4af" +content-hash = "6263c4c38318f4eddab07a8d8e564863c3fb44e316f9285a74f9426d3932034c" diff --git a/pyproject.toml b/pyproject.toml index afda254d..201d199d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,12 +15,12 @@ classifiers = [ requires-python = '>=3.12,<4.0' dependencies = [ - "saic-ismart-client-ng (>=0.9.3,<0.10.0)", + "saic-ismart-client-ng (>=0.10.0,<0.11.0)", 'httpx (>=0.28.1,<0.29.0)', - 'gmqtt (>=0.7.0,<0.8.0)', 'inflection (>=0.5.1,<0.6.0)', 'apscheduler (>=3.11.0,<4.0.0)', 'python-dotenv (>=1.1.1,<2.0.0)', + "aiomqtt (>=2.4.0,<3.0.0)", ] [project.urls] @@ -32,16 +32,16 @@ package-mode = false requires-poetry = '>=2.0,<3.0' [tool.poetry.group.dev.dependencies] -pytest = "^9.0.0" +pytest = ">=9.0.3,<10.0.0" mock = "^5.1.0" -coverage = "^7.5.4" -ruff = "^0.15.0" -pytest-cov = "^7.0.0" -pytest-asyncio = "^1.2.0" -pytest-mock = "^3.14.0" -mypy = "^1.15.0" -pylint = "^4.0.0" -pre-commit = "^4.6.0" +coverage = ">=7.16.0,<8.0.0" +ruff = ">=0.17.1,<0.18.0" +pytest-cov = ">=7.1.0,<8.0.0" +pytest-asyncio = ">=1.4.0,<2.0.0" +pytest-mock = ">=3.16.0,<4.0.0" +mypy = ">=2.4.0,<3.0.0" +pylint = ">=4.1.3,<5.0.0" +pre-commit = ">=4.7.0,<5.0.0" [tool.poetry.dependencies] saic-ismart-client-ng = { develop = true } @@ -88,6 +88,7 @@ exclude_lines = [ ignore_errors = true [tool.ruff] +extend-exclude = [".claude"] include = [ "src/**/*.py", "tests/**/*.py", @@ -130,6 +131,9 @@ ignore = [ "FIX002", # Line contains TODO, consider resolving the issue "BLE001", # Do not catch blind exception: `Exception`, "PLR0913", # Too many arguments in function definition + "PLR0917", # Too many positional arguments in function definition + "CPY001", # Missing copyright notice at top of file + "ICN001", # Import conventions, e.g. `datetime` as `dt` "ERA001", # Commented-out code "PLR0912", # Logging statement uses f-string "G004", # Logging statement uses f-string @@ -187,10 +191,6 @@ strict = true module = ["apscheduler.*"] ignore_missing_imports = true -[[tool.mypy.overrides]] -module = ["gmqtt.*"] -ignore_missing_imports = true -follow_untyped_imports = true [[tool.mypy.overrides]] module = ["publisher.mqtt_publisher"] @@ -198,4 +198,4 @@ disallow_untyped_calls = false [[tool.mypy.overrides]] module = ["configuration.argparse_extensions"] -disable_error_code = ["arg-type"] \ No newline at end of file +disable_error_code = ["arg-type"] diff --git a/src/background_tasks.py b/src/background_tasks.py new file mode 100644 index 00000000..ef4417a3 --- /dev/null +++ b/src/background_tasks.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Coroutine + +LOG = logging.getLogger(__name__) + + +def log_task_failure(task: asyncio.Task[Any]) -> None: + """Log the exception of a finished ``task``, if any. + + Meant as a done-callback, so that a failure is reported when it happens + instead of when the task is garbage collected. + """ + if not task.cancelled() and (exc := task.exception()) is not None: + LOG.error("Background task %r failed", task.get_name(), exc_info=exc) + + +class BackgroundTasks: + """Fire-and-forget tasks that are kept alive and whose failures are logged. + + asyncio only keeps weak references to tasks, so a task nobody references can + be garbage collected mid-run. An exception nobody retrieves is only reported + when the task is collected. ``spawn`` takes care of both. + """ + + def __init__(self) -> None: + self.__tasks: set[asyncio.Task[Any]] = set() + + def __len__(self) -> int: + return len(self.__tasks) + + def spawn( + self, coro: Coroutine[Any, Any, Any], *, name: str | None = None + ) -> asyncio.Task[Any]: + """Run ``coro`` on the running event loop. + + Raises ``RuntimeError`` when called outside of the event loop thread. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError as e: + what = name or getattr(coro, "__qualname__", repr(coro)) + coro.close() + msg = ( + f"Cannot start background task {what!r} outside the event loop " + "thread. Run it from a coroutine, e.g. register scheduler jobs as " + "async functions" + ) + raise RuntimeError(msg) from e + task = loop.create_task(coro, name=name) + self.__tasks.add(task) + task.add_done_callback(self.__on_done) + return task + + async def wait(self) -> None: + """Wait until the tasks running right now are done. + + Tasks are released and failures logged by the time this returns. To bound + the wait, wrap it in ``asyncio.wait_for``: that does not cancel the tasks. + """ + if self.__tasks: + await asyncio.wait(set(self.__tasks)) + + def __on_done(self, task: asyncio.Task[Any]) -> None: + self.__tasks.discard(task) + log_task_failure(task) diff --git a/src/configuration/__init__.py b/src/configuration/__init__.py index 7173f372..63ca6937 100644 --- a/src/configuration/__init__.py +++ b/src/configuration/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from zoneinfo import ZoneInfo @@ -9,8 +9,11 @@ from integrations.openwb.charging_station import ChargingStation +Transport = Literal["tcp", "websockets"] + + class TransportProtocol(Enum): - def __init__(self, transport_mechanism: str, with_tls: bool) -> None: + def __init__(self, transport_mechanism: Transport, with_tls: bool) -> None: self.transport_mechanism = transport_mechanism self.with_tls = with_tls diff --git a/src/configuration/parser.py b/src/configuration/parser.py index 99456ab5..304c295a 100644 --- a/src/configuration/parser.py +++ b/src/configuration/parser.py @@ -105,15 +105,18 @@ def __parse_mqtt_transport(args: Namespace, config: Configuration) -> None: args.tls_server_cert_check_hostname ) else: - msg = f"Invalid MQTT URI scheme: {parse_result.scheme}, use tcp or ws" + msg = f"Invalid MQTT URI scheme: {parse_result.scheme}, use tls, tcp or ws" raise SystemExit(msg) if parse_result.port: config.mqtt_port = parse_result.port - elif config.mqtt_transport_protocol == TransportProtocol.TCP: - config.mqtt_port = 1883 - else: + elif config.mqtt_transport_protocol == TransportProtocol.TLS: + config.mqtt_port = 8883 + elif config.mqtt_transport_protocol == TransportProtocol.WS: config.mqtt_port = 9001 + else: + # fallback to default mqtt port + config.mqtt_port = 1883 config.mqtt_host = str(parse_result.hostname) diff --git a/src/extractors/__init__.py b/src/extractors/__init__.py index 2ebd343d..746a735a 100644 --- a/src/extractors/__init__.py +++ b/src/extractors/__init__.py @@ -43,17 +43,17 @@ def extract_soc_kwh( charge_status: ChrgMgmtDataRespProcessingResult | None, soc: float | None, ) -> float | None: - if ( - charge_status is not None - and (raw_soc_kwh := charge_status.soc_kwh) is not None - and (soc_kwh := __validate_and_convert_soc_kwh(raw_soc_kwh)) is not None - ): + if charge_status is None: + return None + + if (raw_soc_kwh := charge_status.soc_kwh) is not None and ( + soc_kwh := __validate_and_convert_soc_kwh(raw_soc_kwh) + ) is not None: LOG.debug("SoC kWh derived from realtimePower") return soc_kwh if ( soc is not None - and charge_status is not None and ( capacity := __validate_and_convert_soc_kwh( charge_status.real_total_battery_capacity diff --git a/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py b/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py index ebc7f9a9..554389b2 100644 --- a/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py +++ b/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py @@ -23,11 +23,15 @@ def topic(cls) -> str: async def handle_true(self) -> None: LOG.info(f"Vehicle {self.vin} charging cable will be locked") await self.saic_api.control_charging_port_lock(self.vin, unlock=False) + # Some models never report the lock state, so publish the commanded one. + # Models that do report it overwrite it on the forced refresh. + self.vehicle_state.update_charging_cable_lock(locked=True) @override async def handle_false(self) -> None: LOG.info(f"Vehicle {self.vin} charging cable will be unlocked") await self.saic_api.control_charging_port_lock(self.vin, unlock=True) + self.vehicle_state.update_charging_cable_lock(locked=False) @override async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: diff --git a/src/handlers/message.py b/src/handlers/message.py index 64bf508a..b1a339c0 100644 --- a/src/handlers/message.py +++ b/src/handlers/message.py @@ -6,7 +6,7 @@ from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException -from utils import ensure_datetime_aware +from utils import ensure_datetime_aware, is_transient_api_error, log_transient_api_error from vehicle import RefreshMode if TYPE_CHECKING: @@ -84,9 +84,14 @@ async def __polling(self) -> None: ) self.relogin_handler.relogin() except SaicApiException as e: - LOG.exception( - "MessageHandler poll loop failed during SAIC API Call", exc_info=e - ) + if is_transient_api_error(e): + log_transient_api_error( + LOG, "MessageHandler poll failed, will retry", e + ) + else: + LOG.exception( + "MessageHandler poll loop failed during SAIC API Call", exc_info=e + ) except Exception as e: LOG.exception("MessageHandler poll loop failed unexpectedly", exc_info=e) diff --git a/src/handlers/vehicle.py b/src/handlers/vehicle.py index 7f189080..90e84006 100644 --- a/src/handlers/vehicle.py +++ b/src/handlers/vehicle.py @@ -18,6 +18,7 @@ import mqtt_topics from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener from status_publisher.vehicle_info import VehicleInfoPublisher +from utils import is_transient_api_error, log_transient_api_error from vehicle import RefreshMode if TYPE_CHECKING: @@ -168,9 +169,15 @@ async def handle_vehicle(self) -> None: self.relogin_handler.relogin() except SaicApiException as e: self.vehicle_state.mark_failed_refresh() - LOG.exception( - "handle_vehicle loop failed during SAIC API call", exc_info=e - ) + if is_transient_api_error(e): + log_transient_api_error( + LOG, "Vehicle poll failed, will retry", e + ) + else: + LOG.exception( + "handle_vehicle loop failed during SAIC API call", + exc_info=e, + ) except VehicleStatusDriftException as e: self.vehicle_state.mark_failed_refresh() LOG.error( @@ -208,7 +215,9 @@ async def __polling(self) -> None: charge_status, charge_status_processing_result, ) = await self.update_charge_status() + self.vehicle_state.mark_charge_data_received() except Exception as e: + self.vehicle_state.mark_charge_data_failed() LOG.exception("Error updating charge status", exc_info=e) try: diff --git a/src/handlers/vehicle_command.py b/src/handlers/vehicle_command.py index e7f994ca..d9bc706e 100644 --- a/src/handlers/vehicle_command.py +++ b/src/handlers/vehicle_command.py @@ -9,6 +9,7 @@ from exceptions import MqttGatewayException from handlers.command import ALL_COMMAND_HANDLERS, CommandHandlerBase import mqtt_topics +from utils import is_transient_api_error, log_transient_api_error from vehicle import RefreshMode if TYPE_CHECKING: @@ -65,12 +66,16 @@ def __report_command_failure( detail: str, exc: Exception | None = None, ) -> None: - if exc is not None: - LOG.exception("Command %s failed: %s", command, detail, exc_info=exc) + if exc is not None and is_transient_api_error(exc): + log_transient_api_error( + LOG, f"Command {command} failed", exc, level=logging.ERROR + ) + elif exc is not None: + LOG.error("Command %s failed: %s", command, detail, exc_info=exc) else: LOG.error("Command %s failed: %s", command, detail) try: - self.publisher.publish_str(result_topic, f"Failed: {detail}") + self.publisher.publish_str(result_topic, f"Failed: {detail}", retain=False) except Exception: LOG.warning( "Failed to publish failure result for command %s", @@ -138,7 +143,7 @@ async def __execute_mqtt_command_handler( try: execution_result = await handler.handle(payload, retained=retained) - self.publisher.publish_str(result_topic, "Success") + self.publisher.publish_str(result_topic, "Success", retain=False) if execution_result.force_refresh: self.vehicle_state.set_refresh_mode( RefreshMode.FORCE, f"after command execution on topic {topic}" @@ -165,7 +170,7 @@ async def __execute_mqtt_command_handler( return try: execution_result = await handler.handle(payload, retained=retained) - self.publisher.publish_str(result_topic, "Success") + self.publisher.publish_str(result_topic, "Success", retain=False) if execution_result.force_refresh: self.vehicle_state.set_refresh_mode( RefreshMode.FORCE, diff --git a/src/integrations/abrp/api.py b/src/integrations/abrp/api.py index cdaabe94..05ee5b41 100644 --- a/src/integrations/abrp/api.py +++ b/src/integrations/abrp/api.py @@ -153,7 +153,19 @@ async def update_abrp( msg = f"HTTP error: {e}" raise AbrpApiException(msg) from e else: - return False, "ABRP request skipped because of missing configuration" + missing = self.__missing_inputs(vehicle_status, charge_mgmt_data) + return False, f"ABRP request skipped, missing: {', '.join(missing)}" + + def __missing_inputs( + self, vehicle_status: object | None, charge_mgmt_data: object | None + ) -> list[str]: + required = { + "ABRP API key": self.abrp_api_key, + "ABRP user token": self.abrp_user_token, + "vehicle status data": vehicle_status, + "charge management data": charge_mgmt_data, + } + return [name for name, value in required.items() if value is None] @staticmethod def __extract_basic_vehicle_status( diff --git a/src/integrations/home_assistant/base.py b/src/integrations/home_assistant/base.py index 322fa60a..8f12dbe4 100644 --- a/src/integrations/home_assistant/base.py +++ b/src/integrations/home_assistant/base.py @@ -55,7 +55,7 @@ def _publish_select( "command_topic": self._get_command_topic(topic), "value_template": value_template, "command_template": command_template, - "retain": str(retain).lower(), + "retain": retain, "options": options, "enabled_by_default": enabled, } @@ -87,7 +87,7 @@ def _publish_text( "command_topic": self._get_command_topic(topic), "value_template": value_template, "command_template": command_template, - "retain": str(retain).lower(), + "retain": retain, "enabled_by_default": enabled, } if min_value is not None: @@ -153,7 +153,7 @@ def _publish_number( "state_topic": self._get_state_topic(topic), "command_topic": self._get_command_topic(topic), "value_template": value_template, - "retain": str(retain).lower(), + "retain": retain, "mode": mode, "min": min_value, "max": max_value, diff --git a/src/integrations/home_assistant/discovery.py b/src/integrations/home_assistant/discovery.py index 91a94bfa..0fa3e51a 100644 --- a/src/integrations/home_assistant/discovery.py +++ b/src/integrations/home_assistant/discovery.py @@ -376,12 +376,25 @@ def __publish_ha_discovery_messages_real(self) -> None: LOG.debug("Completed publishing Home Assistant discovery messages") def __publish_drivetrain_charging_sensors(self) -> None: + charge_data_availability = HaCustomAvailabilityEntry( + topic=self.__get_vehicle_topic( + mqtt_topics.DRIVETRAIN_CHARGING_DATA_AVAILABLE + ) + ) + charge_data_availability_config = HaCustomAvailabilityConfig( + rules=[ + self.__system_availability, + self.__vehicle_availability, + charge_data_availability, + ] + ) self._publish_switch(mqtt_topics.DRIVETRAIN_CHARGING, "Charging") self._publish_binary_sensor( mqtt_topics.DRIVETRAIN_CHARGING, "Battery Charging", device_class="battery_charging", icon="mdi:battery-charging", + custom_availability=charge_data_availability_config, ) self._publish_sensor( mqtt_topics.DRIVETRAIN_CHARGING_STOP_REASON, @@ -444,6 +457,7 @@ def __publish_drivetrain_charging_sensors(self) -> None: device_class="duration", state_class="measurement", unit_of_measurement="s", + custom_availability=charge_data_availability_config, ) self._publish_sensor( mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, @@ -454,6 +468,7 @@ def __publish_drivetrain_charging_sensors(self) -> None: rules=[ self.__system_availability, self.__vehicle_availability, + charge_data_availability, HaCustomAvailabilityEntry( topic=self.__get_vehicle_topic( mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME diff --git a/src/integrations/home_assistant/utils.py b/src/integrations/home_assistant/utils.py index 61b6e469..35af6292 100644 --- a/src/integrations/home_assistant/utils.py +++ b/src/integrations/home_assistant/utils.py @@ -12,7 +12,7 @@ def snake_case(s: str) -> str: def decode_as_utf8( - byte_string: str | None | bytes | bytearray, default: str = "" + byte_string: str | bytes | bytearray | None, default: str = "" ) -> str: if byte_string is None: return default diff --git a/src/log_config.py b/src/log_config.py index 9c96caed..2a49e3a4 100644 --- a/src/log_config.py +++ b/src/log_config.py @@ -7,7 +7,7 @@ MODULES_DEFAULT_LOG_LEVEL = { "asyncio": "WARNING", - "gmqtt": "WARNING", + "aiomqtt": "WARNING", "httpcore": "WARNING", "httpx": "WARNING", "saic_ismart_client_ng": "WARNING", diff --git a/src/main.py b/src/main.py index dc47e092..6706f0a2 100644 --- a/src/main.py +++ b/src/main.py @@ -3,12 +3,13 @@ import asyncio import faulthandler import logging -import os import signal import sys from configuration.parser import process_command_line from mqtt_gateway import MqttGateway +from shutdown import run_until_terminated +from utils import get_gateway_version if __name__ == "__main__": # Keep this at the top! @@ -16,9 +17,7 @@ setup_logging() log = logging.getLogger(__name__) - log.info( - f"Starting SAIC MQTT Gateway version {os.environ.get('RELEASE_VERSION', 'unknown')}" - ) + log.info(f"Starting SAIC MQTT Gateway version {get_gateway_version()}") # Enable fault handler to get a thread dump on SIGQUIT faulthandler.enable(file=sys.stderr, all_threads=True) @@ -27,4 +26,5 @@ configuration = process_command_line() mqtt_gateway = MqttGateway(configuration) - asyncio.run(mqtt_gateway.run(), debug=debug_log_enabled()) + + asyncio.run(run_until_terminated(mqtt_gateway.run()), debug=debug_log_enabled()) diff --git a/src/mqtt_gateway.py b/src/mqtt_gateway.py index f955e930..a9adf720 100644 --- a/src/mqtt_gateway.py +++ b/src/mqtt_gateway.py @@ -96,7 +96,12 @@ def __select_publisher(self) -> Publisher: async def run(self) -> None: LOG.info("Connecting to MQTT Broker") await self.publisher.connect() + try: + await self.__start_and_supervise() + finally: + await self.__shutdown() + async def __start_and_supervise(self) -> None: self.__relogin_handler.add_post_login_callback(self.__on_login_success) self.__relogin_handler.add_post_login_callback(self.__refresh_account_data) self.__relogin_handler.add_login_failure_callback(self.__on_login_failure) @@ -136,6 +141,25 @@ async def run(self) -> None: LOG.info("Entering main loop") await self.__run_until_all_tasks_done() + async def __shutdown(self) -> None: + try: + await self.__flush_publisher() + finally: + # A second cancellation during the flush must not skip the close + await self.__close_saic_api() + + async def __flush_publisher(self) -> None: + try: + await self.publisher.flush() + except Exception: + LOG.warning("Failed to flush pending MQTT publishes", exc_info=True) + + async def __close_saic_api(self) -> None: + try: + await self.saic_api.close() + except Exception: + LOG.warning("Failed to close the SAIC API client", exc_info=True) + async def __fetch_user_timezone(self) -> ZoneInfo | None: try: resp = await self.saic_api.get_user_timezone() @@ -316,7 +340,7 @@ async def __refresh_vehicle_list(self) -> None: for vin in known_vins - api_vins: LOG.warning("Vehicle %s no longer in API vehicle list, stopping", vin) vh = self.vehicle_handlers.pop(vin) - vh.vehicle_state.mark_failed_refresh() + vh.vehicle_state.mark_failed_refresh(force_offline=True) task = self.__cancel_vehicle_task(vin) if task is not None: with contextlib.suppress(asyncio.CancelledError): @@ -433,23 +457,46 @@ def get_charging_station(self, vin: str) -> ChargingStation | None: return self.configuration.charging_stations_by_vin[vin] return None + @staticmethod + def __on_transport_task_done(task: asyncio.Task[Any]) -> None: + """Exit when the transport is gone: the gateway cannot work without it. + + A cancelled task means we are shutting down, so that is not an error. + """ + if task.cancelled(): + LOG.debug(f"{task.get_name()!r} task was cancelled") + else: + # A crash is already logged with its traceback by log_task_failure + LOG.error(f"{task.get_name()!r} task terminated unexpectedly, exiting") + raise SystemExit(-1) + async def __run_until_all_tasks_done(self) -> None: while True: # Clean up completed tasks self.__vehicle_tasks = [t for t in self.__vehicle_tasks if not t.done()] - if not self.__vehicle_tasks: + transport_task = self.publisher.connection_task() + if transport_task is not None and transport_task.done(): + self.__on_transport_task_done(transport_task) + transport_task = None + watched: list[asyncio.Task[Any]] = list(self.__vehicle_tasks) + if transport_task is not None: + watched.append(transport_task) + if not watched: await asyncio.sleep(1.0) continue + # The timeout lets vehicle tasks started in the meantime get watched too done, _pending = await asyncio.wait( - self.__vehicle_tasks, return_when=asyncio.FIRST_COMPLETED + watched, timeout=1.0, return_when=asyncio.FIRST_COMPLETED ) for task in done: + if task is transport_task: + continue # handled at the top of the next iteration task_name = task.get_name() if task.cancelled(): LOG.debug(f"{task_name!r} task was cancelled") elif (exception := task.exception()) is not None: - LOG.exception( + LOG.error( f"{task_name!r} task crashed with an exception", exc_info=exception, ) diff --git a/src/mqtt_topics.py b/src/mqtt_topics.py index cf91614f..818e3f07 100644 --- a/src/mqtt_topics.py +++ b/src/mqtt_topics.py @@ -53,6 +53,7 @@ DRIVETRAIN_CHARGER_CONNECTED = DRIVETRAIN + "/chargerConnected" DRIVETRAIN_CHARGING = DRIVETRAIN + "/charging" DRIVETRAIN_CHARGING_SET = DRIVETRAIN_CHARGING + "/" + SET_SUFFIX +DRIVETRAIN_CHARGING_DATA_AVAILABLE = DRIVETRAIN + "/chargingDataAvailable" DRIVETRAIN_CHARGING_STOP_REASON = DRIVETRAIN + "/chargingStopReason" DRIVETRAIN_CHARGING_LAST_START = DRIVETRAIN_CHARGING + "/lastStart" DRIVETRAIN_CHARGING_LAST_END = DRIVETRAIN_CHARGING + "/lastEnd" diff --git a/src/publisher/core.py b/src/publisher/core.py index 6e7e9265..af36e8ba 100644 --- a/src/publisher/core.py +++ b/src/publisher/core.py @@ -4,15 +4,15 @@ from datetime import datetime import json import re -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any import mqtt_topics from utils import datetime_to_str if TYPE_CHECKING: - from configuration import Configuration + import asyncio -T = TypeVar("T") + from configuration import Configuration type Publishable = bool | int | float | str | dict[str, Any] | datetime """Closed union of value types this gateway knows how to publish to MQTT. @@ -90,6 +90,18 @@ async def connect(self) -> None: def enable_commands(self) -> None: pass + @abstractmethod + async def flush(self) -> None: + """Give publishes still in flight a chance to be delivered, e.g. on shutdown.""" + + def connection_task(self) -> asyncio.Task[Any] | None: + """Return the long-lived task that keeps the transport up, if there is one. + + The gateway watches it and exits when it ends, since it cannot work + without its transport. Publishers without a transport return ``None``. + """ + return None + @abstractmethod def is_connected(self) -> bool: raise NotImplementedError @@ -102,30 +114,55 @@ def publish_json( no_prefix: bool = False, *, retain: bool = True, + qos: int = 0, ) -> None: raise NotImplementedError @abstractmethod def publish_str( - self, key: str, value: str, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: str, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: raise NotImplementedError @abstractmethod def publish_int( - self, key: str, value: int, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: int, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: raise NotImplementedError @abstractmethod def publish_bool( - self, key: str, value: bool, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: bool, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: raise NotImplementedError @abstractmethod def publish_float( - self, key: str, value: float, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: float, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: raise NotImplementedError @@ -173,7 +210,7 @@ def publish( raise TypeError(msg) @abstractmethod - def clear_topic(self, key: str, no_prefix: bool = False) -> None: + def clear_topic(self, key: str, no_prefix: bool = False, qos: int = 0) -> None: raise NotImplementedError def get_mqtt_account_prefix(self) -> str: @@ -200,7 +237,7 @@ def __remove_byte_strings(self, data: dict[str, Any]) -> dict[str, Any]: self.__remove_byte_strings(item) return data - def __anonymize(self, data: T) -> T: + def __anonymize[T](self, data: T) -> T: if isinstance(data, dict): for key in data: if isinstance(data[key], str): @@ -249,7 +286,9 @@ def __anonymize(self, data: T) -> T: return data def keepalive(self) -> None: - self.publish_str(mqtt_topics.INTERNAL_LWT, "online", False) + self.publish_str( + mqtt_topics.INTERNAL_LWT, "online", no_prefix=False, retain=True, qos=1 + ) @staticmethod def anonymize_str(value: str) -> str: diff --git a/src/publisher/log_publisher.py b/src/publisher/log_publisher.py index 7969c61e..bacc0178 100644 --- a/src/publisher/log_publisher.py +++ b/src/publisher/log_publisher.py @@ -14,6 +14,10 @@ class ConsolePublisher(Publisher): async def connect(self) -> None: pass + @override + async def flush(self) -> None: + pass + @override def is_connected(self) -> bool: return True @@ -30,36 +34,61 @@ def publish_json( no_prefix: bool = False, *, retain: bool = True, + qos: int = 0, ) -> None: anonymized_json = self.dict_to_anonymized_json(data) self.internal_publish(key, anonymized_json, retain=retain) @override def publish_str( - self, key: str, value: str, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: str, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.internal_publish(key, value, retain=retain) @override def publish_int( - self, key: str, value: int, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: int, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.internal_publish(key, value, retain=retain) @override def publish_bool( - self, key: str, value: bool, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: bool, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.internal_publish(key, value, retain=retain) @override def publish_float( - self, key: str, value: float, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: float, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.internal_publish(key, value, retain=retain) @override - def clear_topic(self, key: str, no_prefix: bool = False) -> None: + def clear_topic(self, key: str, no_prefix: bool = False, qos: int = 0) -> None: self.internal_publish(key, None) def internal_publish( diff --git a/src/publisher/mqtt_publisher.py b/src/publisher/mqtt_publisher.py index ed535d48..bca839f1 100644 --- a/src/publisher/mqtt_publisher.py +++ b/src/publisher/mqtt_publisher.py @@ -1,12 +1,15 @@ from __future__ import annotations +import asyncio import logging import math import ssl -from typing import TYPE_CHECKING, Any, Final, cast, override +from typing import TYPE_CHECKING, Any, override -import gmqtt +import aiomqtt +from aiomqtt.exceptions import MqttConnectError +from background_tasks import BackgroundTasks, log_task_failure import mqtt_topics from publisher.core import Publisher @@ -17,9 +20,29 @@ LOG = logging.getLogger(__name__) +# Reconnect backoff: starts at 5 s, doubles on each failure, caps at 5 min +_RECONNECT_INTERVAL_MIN = 5 +_RECONNECT_INTERVAL_MAX = 300 +_FLUSH_TIMEOUT = 2.0 + +# MQTT 3.1.1 spec section 3.2.2.3 — permanent connection refusal codes +_CONNACK_REFUSED_PROTOCOL_VERSION = 1 +_CONNACK_REFUSED_IDENTIFIER_REJECTED = 2 +_CONNACK_REFUSED_BAD_CREDENTIALS = 4 +_CONNACK_REFUSED_NOT_AUTHORIZED = 5 +_FATAL_CONNECT_RC = { + _CONNACK_REFUSED_PROTOCOL_VERSION, + _CONNACK_REFUSED_IDENTIFIER_REJECTED, + _CONNACK_REFUSED_BAD_CREDENTIALS, + _CONNACK_REFUSED_NOT_AUTHORIZED, +} + class MqttPublisher(Publisher): - def __init__(self, configuration: Configuration) -> None: + def __init__( + self, + configuration: Configuration, + ) -> None: super().__init__(configuration) self.publisher_id = configuration.mqtt_client_id self.host = self.configuration.mqtt_host @@ -30,129 +53,230 @@ def __init__(self, configuration: Configuration) -> None: self.vin_by_charger_connected_topic: dict[str, str] = {} self.vin_by_imported_energy_topic: dict[str, str] = {} self.first_connection = True + self.client: aiomqtt.Client | None = None + self.__running: asyncio.Task[None] | None = None + self.__background_tasks = BackgroundTasks() + self.__connected = asyncio.Event() + self.__fatal_connect_error: SystemExit | None = None + + async def __run_loop(self) -> None: + if not self.host: + LOG.info("MQTT host is not configured") + return + ssl_context: ssl.SSLContext | None = None + if self.transport_protocol.with_tls: + ssl_context = ssl.create_default_context() + if self.configuration.tls_server_cert_path: + LOG.debug( + f"Using custom CA file {self.configuration.tls_server_cert_path}" + ) + ssl_context.load_verify_locations( + cafile=self.configuration.tls_server_cert_path + ) + if not self.configuration.tls_server_cert_check_hostname: + LOG.warning( + f"Skipping hostname check for TLS connection to {self.host}" + ) - mqtt_client = gmqtt.Client( - client_id=str(self.publisher_id), + client = aiomqtt.Client( + hostname=self.host, + port=self.port, + identifier=str(self.publisher_id), transport=self.transport_protocol.transport_mechanism, - will_message=gmqtt.Message( + username=self.configuration.mqtt_user or None, + password=self.configuration.mqtt_password or None, + clean_session=True, + tls_context=ssl_context, + tls_insecure=( + True + if ssl_context and not self.configuration.tls_server_cert_check_hostname + else None + ), + will=aiomqtt.Will( topic=self.get_topic(mqtt_topics.INTERNAL_LWT, False), payload="offline", retain=True, + qos=1, ), ) - mqtt_client.on_connect = self.__on_connect - mqtt_client.on_message = self.__on_message - self.client: Final[gmqtt.Client] = mqtt_client + client.pending_calls_threshold = 150 + reconnect_interval = _RECONNECT_INTERVAL_MIN + while True: + try: + LOG.debug( + "Connecting to %s:%s as %s", + self.host, + self.port, + self.publisher_id, + ) + async with client as client_context: + self.client = client_context + self.__connected.set() + await self.__on_connect() + reconnect_interval = _RECONNECT_INTERVAL_MIN + async for message in client_context.messages: + await self._on_message( + client_context, + str(message.topic), + message.payload, + message.qos, + message.retain, + ) + except MqttConnectError as e: + # Permanent rejections — retrying won't help. + # rc 3 (server unavailable) is transient and falls through to reconnect. + # ReasonCode.__eq__ supports int comparison, so no isinstance guard needed. + if e.rc in _FATAL_CONNECT_RC: + LOG.error("MQTT connection permanently refused: %s", e) + self.__fatal_connect_error = SystemExit(str(e)) + self.__connected.set() + return + LOG.warning( + "Connection to %s:%s refused (transient); Reconnecting in %d seconds ...", + self.host, + self.port, + reconnect_interval, + ) + await asyncio.sleep(reconnect_interval) + reconnect_interval = min( + reconnect_interval * 2, _RECONNECT_INTERVAL_MAX + ) + except aiomqtt.MqttError: + LOG.warning( + "Connection to %s:%s lost; Reconnecting in %d seconds ...", + self.host, + self.port, + reconnect_interval, + ) + await asyncio.sleep(reconnect_interval) + reconnect_interval = min( + reconnect_interval * 2, _RECONNECT_INTERVAL_MAX + ) + except asyncio.exceptions.CancelledError: + LOG.debug("MQTT publisher loop cancelled") + raise + finally: + self.__connected.clear() + LOG.info("MQTT client disconnected") @override async def connect(self) -> None: - if self.configuration.mqtt_user is not None: - if self.configuration.mqtt_password is not None: - self.client.set_auth_credentials( - username=self.configuration.mqtt_user, - password=self.configuration.mqtt_password, - ) - else: - self.client.set_auth_credentials(username=self.configuration.mqtt_user) + if self.__running and not self.__running.done(): + LOG.warning("MQTT client is already running") + return + if not self.host: + LOG.info("MQTT host is not configured") + return + self.__running = asyncio.create_task(self.__run_loop(), name="mqtt-loop") + self.__running.add_done_callback(log_task_failure) + connected = asyncio.create_task(self.__connected.wait()) + try: + # Also stop waiting if the loop dies, else connect() would hang forever + await asyncio.wait( + {self.__running, connected}, return_when=asyncio.FIRST_COMPLETED + ) + finally: + connected.cancel() + if self.__fatal_connect_error is not None: + raise self.__fatal_connect_error + if not self.__connected.is_set(): + if (exc := self.__running.exception()) is not None: + raise exc + msg = "MQTT loop ended before connecting to the broker" + raise RuntimeError(msg) - if self.transport_protocol.with_tls: - ssl_context = ssl.create_default_context() - cert_uri = self.configuration.tls_server_cert_path - if cert_uri: - LOG.debug(f"Using custom CA file {cert_uri}") - ssl_context.load_verify_locations(cafile=cert_uri) - if not self.configuration.tls_server_cert_check_hostname: - LOG.warning( - f"Skipping hostname check for TLS connection to {self.host}" - ) - ssl_context.check_hostname = False - else: - ssl_context = None - await self.client.connect( - host=self.host, - port=self.port, - version=gmqtt.constants.MQTTv311, - ssl=ssl_context, - ) + @override + async def flush(self) -> None: + pending = len(self.__background_tasks) + if pending == 0: + return + LOG.info("Waiting for %d pending MQTT publishes", pending) + try: + await asyncio.wait_for(self.__background_tasks.wait(), _FLUSH_TIMEOUT) + except TimeoutError: + LOG.warning( + "Gave up waiting for %d MQTT publishes after %.1fs", + len(self.__background_tasks), + _FLUSH_TIMEOUT, + ) - def __on_connect( - self, _client: Any, _flags: Any, rc: int, _properties: Any - ) -> None: - if rc == gmqtt.constants.CONNACK_ACCEPTED: - LOG.info("Connected to MQTT broker") - if not self.first_connection: - self.enable_commands() - if self.command_listener is not None: - self.command_listener.on_mqtt_reconnected() - self.first_connection = False - self.keepalive() - else: - if rc == gmqtt.constants.CONNACK_REFUSED_BAD_USERNAME_PASSWORD: - LOG.error( - f"MQTT connection error: bad username or password. Return code {rc}" - ) - elif rc == gmqtt.constants.CONNACK_REFUSED_PROTOCOL_VERSION: - LOG.error( - f"MQTT connection error: refused protocol version. Return code {rc}" - ) - else: - LOG.error(f"MQTT connection error.Return code {rc}") - msg = f"Unable to connect to MQTT broker. Return code: {rc}" - raise SystemExit(msg) + @override + def connection_task(self) -> asyncio.Task[None] | None: + return self.__running + + async def __on_connect(self) -> None: + LOG.info("Connected to MQTT broker") + if not self.first_connection: + await self.__enable_commands() + if self.command_listener is not None: + self.command_listener.on_mqtt_reconnected() + self.first_connection = False + self.keepalive() @override def enable_commands(self) -> None: - LOG.info("Subscribing to MQTT command topics") - mqtt_account_prefix = self.get_mqtt_account_prefix() - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}" - ) - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}" - ) - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}" - ) - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}" - ) - for charging_station in self.configuration.charging_stations_by_vin.values(): - LOG.debug( - f"Subscribing to MQTT topic {charging_station.charge_state_topic}" + self.__background_tasks.spawn(self.__enable_commands(), name="enable_commands") + + async def __enable_commands(self) -> None: + if not self.__connected.is_set() or not self.client: + LOG.error("Failed to enable commands: MQTT client is not connected") + return + try: + LOG.info("Subscribing to MQTT command topics") + mqtt_account_prefix = self.get_mqtt_account_prefix() + await self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}" + ) + await self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}" + ) + await self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}" ) - self.vin_by_charge_state_topic[charging_station.charge_state_topic] = ( - charging_station.vin + await self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}" ) - self.client.subscribe(charging_station.charge_state_topic) - if charging_station.connected_topic: + for ( + charging_station + ) in self.configuration.charging_stations_by_vin.values(): LOG.debug( - f"Subscribing to MQTT topic {charging_station.connected_topic}" + f"Subscribing to MQTT topic {charging_station.charge_state_topic}" ) - self.vin_by_charger_connected_topic[ - charging_station.connected_topic - ] = charging_station.vin - self.client.subscribe(charging_station.connected_topic) - if charging_station.imported_energy_topic: - LOG.debug( - f"Subscribing to MQTT topic {charging_station.imported_energy_topic}" + self.vin_by_charge_state_topic[charging_station.charge_state_topic] = ( + charging_station.vin ) - self.vin_by_imported_energy_topic[ - charging_station.imported_energy_topic - ] = charging_station.vin - self.client.subscribe(charging_station.imported_energy_topic) - if self.configuration.ha_discovery_enabled: - # enable dynamic discovery pushing in case ha reconnects - self.client.subscribe(self.configuration.ha_lwt_topic) - - async def __on_message( - self, _client: Any, topic: str, payload: Any, _qos: Any, _properties: Any + await self.client.subscribe(charging_station.charge_state_topic) + if charging_station.connected_topic: + LOG.debug( + f"Subscribing to MQTT topic {charging_station.connected_topic}" + ) + self.vin_by_charger_connected_topic[ + charging_station.connected_topic + ] = charging_station.vin + await self.client.subscribe(charging_station.connected_topic) + if charging_station.imported_energy_topic: + LOG.debug( + f"Subscribing to MQTT topic {charging_station.imported_energy_topic}" + ) + self.vin_by_imported_energy_topic[ + charging_station.imported_energy_topic + ] = charging_station.vin + await self.client.subscribe(charging_station.imported_energy_topic) + if self.configuration.ha_discovery_enabled: + # enable dynamic discovery pushing in case ha reconnects + await self.client.subscribe(self.configuration.ha_lwt_topic) + except aiomqtt.MqttError as e: + LOG.error(f"Failed to subscribe to MQTT command topics: {e}") + raise e + + async def _on_message( + self, _client: Any, topic: str, payload: Any, _qos: Any, retained: bool ) -> None: try: if isinstance(payload, bytes): payload = payload.decode("utf-8") else: payload = str(payload) - retained = bool(_properties.get("retain", 0)) await self.__on_message_real( topic=topic, payload=payload, retained=retained ) @@ -228,13 +352,35 @@ async def __handle_imported_energy(self, topic: str, payload: str) -> None: ) def __publish( - self, topic: str, payload: WirePayload | None, *, retain: bool = True + self, + topic: str, + payload: WirePayload | None, + *, + retain: bool = True, + qos: int = 0, ) -> None: - self.client.publish(topic, payload, retain=retain) + LOG.debug("Publishing to MQTT topic %s with payload %s", topic, payload) + self.__background_tasks.spawn( + self.__async_publish(topic, payload, retain=retain, qos=qos), + name=f"publish {topic}", + ) + + async def __async_publish( + self, topic: str, payload: Any, retain: bool, qos: int + ) -> None: + if not (self.client and self.is_connected()): + LOG.error("Failed to publish: MQTT client is not connected") + return + try: + await self.client.publish(topic, payload, retain=retain, qos=qos) + except aiomqtt.MqttError as e: + LOG.error( + f"Failed to publish to MQTT topic {topic} with payload {payload}: {e}" + ) @override def is_connected(self) -> bool: - return cast("bool", self.client.is_connected) + return self.__connected.is_set() @override def publish_json( @@ -244,47 +390,75 @@ def publish_json( no_prefix: bool = False, *, retain: bool = True, + qos: int = 0, ) -> None: payload = self.dict_to_anonymized_json(data) self.__publish( - topic=self.get_topic(key, no_prefix), payload=payload, retain=retain + topic=self.get_topic(key, no_prefix), + payload=payload, + retain=retain, + qos=qos, ) @override def publish_str( - self, key: str, value: str, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: str, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.__publish( - topic=self.get_topic(key, no_prefix), payload=value, retain=retain + topic=self.get_topic(key, no_prefix), payload=value, retain=retain, qos=qos ) @override def publish_int( - self, key: str, value: int, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: int, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.__publish( - topic=self.get_topic(key, no_prefix), payload=value, retain=retain + topic=self.get_topic(key, no_prefix), payload=value, retain=retain, qos=qos ) @override def publish_bool( - self, key: str, value: bool, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: bool, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.__publish( - topic=self.get_topic(key, no_prefix), payload=value, retain=retain + topic=self.get_topic(key, no_prefix), payload=value, retain=retain, qos=qos ) @override def publish_float( - self, key: str, value: float, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: float, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: self.__publish( - topic=self.get_topic(key, no_prefix), payload=value, retain=retain + topic=self.get_topic(key, no_prefix), payload=value, retain=retain, qos=qos ) @override - def clear_topic(self, key: str, no_prefix: bool = False) -> None: - self.__publish(topic=self.get_topic(key, no_prefix), payload=None) + def clear_topic(self, key: str, no_prefix: bool = False, qos: int = 0) -> None: + self.__publish(topic=self.get_topic(key, no_prefix), payload=None, qos=qos) def get_vin_from_topic(self, topic: str) -> str: global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1 :] diff --git a/src/shutdown.py b/src/shutdown.py new file mode 100644 index 00000000..61157b9b --- /dev/null +++ b/src/shutdown.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import asyncio +import logging +import signal +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Coroutine + +LOG = logging.getLogger(__name__) + + +async def run_until_terminated(main: Coroutine[Any, Any, None]) -> None: + """Run ``main``, cancelling it cleanly when the process receives SIGTERM. + + Python does not handle SIGTERM by default, and as PID 1 in a container it + would simply ignore it. Cancelling the task lets its ``finally`` blocks run. + """ + loop = asyncio.get_running_loop() + task = loop.create_task(main) + terminated = False + + def request_shutdown() -> None: + nonlocal terminated + terminated = True + LOG.info("Received SIGTERM, shutting down") + task.cancel() + + handler_installed = False + try: + loop.add_signal_handler(signal.SIGTERM, request_shutdown) + handler_installed = True + except (NotImplementedError, RuntimeError, ValueError): + LOG.debug("Cannot install a SIGTERM handler on this platform") + + try: + await task + except asyncio.CancelledError: + if not terminated: + raise + finally: + if handler_installed: + loop.remove_signal_handler(signal.SIGTERM) diff --git a/src/status_publisher/charge/chrg_mgmt_data.py b/src/status_publisher/charge/chrg_mgmt_data.py index b0444fc9..b3a24822 100644 --- a/src/status_publisher/charge/chrg_mgmt_data.py +++ b/src/status_publisher/charge/chrg_mgmt_data.py @@ -104,18 +104,18 @@ def publish(self, charge_mgmt_data: ChrgMgmtData) -> ChrgMgmtDataProcessingResul self._transform_and_publish( topic=mqtt_topics.BMS_CHARGE_STATUS, value=charge_mgmt_data.bms_charging_status, - transform=lambda x: f"UNKNOWN {charge_mgmt_data.bmsChrgSts}" - if x is None - else x.name, + transform=lambda x: ( + f"UNKNOWN {charge_mgmt_data.bmsChrgSts}" if x is None else x.name + ), ) self._transform_and_publish( topic=mqtt_topics.DRIVETRAIN_CHARGING_STOP_REASON, value=charge_mgmt_data.charging_stop_reason, validator=lambda x: x != ChargingStopReason.NO_REASON, - transform=lambda x: f"UNKNOWN {charge_mgmt_data.bmsChrgSpRsn}" - if x is None - else x.name, + transform=lambda x: ( + f"UNKNOWN {charge_mgmt_data.bmsChrgSpRsn}" if x is None else x.name + ), ) self._publish( @@ -153,14 +153,19 @@ def publish(self, charge_mgmt_data: ChrgMgmtData) -> ChrgMgmtDataProcessingResul topic=mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON, value=charge_mgmt_data.heating_stop_reason, validator=lambda x: x != HeatingStopReason.NO_REASON, - transform=lambda x: f"UNKNOWN ({charge_mgmt_data.bmsPTCHeatResp})" - if x is None - else x.name, + transform=lambda x: ( + f"UNKNOWN ({charge_mgmt_data.bmsPTCHeatResp})" if x is None else x.name + ), ) self._transform_and_publish( topic=mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK, - value=charge_mgmt_data.charging_port_locked, + # Some models (e.g. MG ZS EV) do not report the lock state at all + value=( + charge_mgmt_data.charging_port_locked + if charge_mgmt_data.ccuEleccLckCtrlDspCmd is not None + else None + ), transform=int_to_bool, ) diff --git a/src/utils.py b/src/utils.py index 7c0778e3..db92a224 100644 --- a/src/utils.py +++ b/src/utils.py @@ -8,7 +8,9 @@ from typing import TYPE_CHECKING from zoneinfo import ZoneInfo +import httpx from saic_ismart_client_ng.api.schema import GpsStatus +from saic_ismart_client_ng.exceptions import SaicApiRetryException if TYPE_CHECKING: from saic_ismart_client_ng.api.vehicle import VehicleStatusResp @@ -16,6 +18,32 @@ LOG = logging.getLogger(__name__) +def is_transient_api_error(exc: BaseException) -> bool: + """Whether ``exc`` is a transient SAIC API failure. + + That is an HTTP timeout, or the server asking the client to retry later. + """ + return isinstance(exc, SaicApiRetryException) or isinstance( + exc.__cause__, httpx.TimeoutException + ) + + +def log_transient_api_error( + logger: logging.Logger, + context: str, + exc: BaseException, + level: int = logging.WARNING, +) -> None: + """Log a transient API error as one line; keep the traceback for debug.""" + logger.log( + level, + "%s: SAIC API is not answering or is busy: %s", + context, + exc, + exc_info=exc if logger.isEnabledFor(logging.DEBUG) else None, + ) + + def parse_timezone(tz_str: str) -> ZoneInfo: """Parse a timezone string into a :class:`ZoneInfo`. @@ -85,10 +113,14 @@ def get_update_timestamp(vehicle_status: VehicleStatusResp) -> datetime: def get_gateway_version() -> str: + # The release version set at build time wins over the package metadata, + # whose version in pyproject.toml is not kept in sync with the release tags + if release_version := os.environ.get("RELEASE_VERSION"): + return release_version try: return version("saic-python-mqtt-gateway") except PackageNotFoundError: - return os.environ.get("RELEASE_VERSION", "unknown") + return "unknown" def ensure_datetime_aware(dt: datetime) -> datetime: diff --git a/src/vehicle.py b/src/vehicle.py index 8a36e403..428b59b7 100644 --- a/src/vehicle.py +++ b/src/vehicle.py @@ -44,6 +44,10 @@ DEFAULT_AC_TEMP = 22 PRESSURE_TO_BAR_FACTOR = 0.04 +# Consecutive failed polls before the vehicle is marked offline +VEHICLE_FAILURES_BEFORE_OFFLINE = 3 +# Consecutive charge data failures before charging entities are marked unavailable +CHARGE_DATA_FAILURES_BEFORE_UNAVAILABLE = 3 LOG = logging.getLogger(__name__) @@ -113,6 +117,9 @@ def __init__( ) self.__last_failed_refresh: datetime.datetime | None = None self.__failed_refresh_counter = 0 + self.__consecutive_failed_refreshes = 0 + self.__failed_charge_data_counter = 0 + self.__charge_data_available: bool | None = None self.__refresh_period_error = 30 self.last_car_shutdown: datetime.datetime = datetime.datetime.now( tz=datetime.UTC @@ -272,10 +279,8 @@ def update_scheduled_charging( ) else: self.__scheduler.add_job( - func=self.set_refresh_mode, - args=[RefreshMode.FORCE, "check for scheduled charging start"], + func=self.__force_refresh_for_scheduled_charging, trigger=trigger, - kwargs={}, name=scheduled_charging_job_id, id=scheduled_charging_job_id, replace_existing=True, @@ -321,6 +326,11 @@ def set_is_charging(self, is_charging: bool) -> None: self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), self.is_charging ) + def update_charging_cable_lock(self, *, locked: bool) -> None: + self.publisher.publish_bool( + self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK), locked + ) + def handle_vehicle_status( self, vehicle_status: VehicleStatusResp ) -> VehicleStatusRespProcessingResult: @@ -472,11 +482,36 @@ def __should_do_periodic_refresh(self) -> bool: def mark_successful_refresh(self) -> None: self.last_successful_refresh = datetime.datetime.now(tz=datetime.UTC) self.last_failed_refresh = None + self.__consecutive_failed_refreshes = 0 self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "online") - def mark_failed_refresh(self) -> None: + def mark_charge_data_received(self) -> None: + self.__failed_charge_data_counter = 0 + self.__publish_charge_data_available(available=True) + + def mark_charge_data_failed(self) -> None: + self.__failed_charge_data_counter += 1 + if self.__failed_charge_data_counter >= CHARGE_DATA_FAILURES_BEFORE_UNAVAILABLE: + self.__publish_charge_data_available(available=False) + + def __publish_charge_data_available(self, *, available: bool) -> None: + if self.__charge_data_available == available: + return + self.__charge_data_available = available + self.publisher.publish_str( + self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_DATA_AVAILABLE), + "online" if available else "offline", + ) + + def mark_failed_refresh(self, *, force_offline: bool = False) -> None: self.last_failed_refresh = datetime.datetime.now(tz=datetime.UTC) - self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "offline") + self.__consecutive_failed_refreshes += 1 + # Tolerate a few intermittent failures before flipping entities to unavailable + if ( + force_offline + or self.__consecutive_failed_refreshes >= VEHICLE_FAILURES_BEFORE_OFFLINE + ): + self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "offline") @property def refresh_period_error(self) -> int: @@ -726,6 +761,11 @@ def __publish_polling_phase(self, phase: PollingPhase) -> None: self.get_topic(mqtt_topics.REFRESH_POLLING_PHASE), phase.value ) + async def __force_refresh_for_scheduled_charging(self) -> None: + # Must be a coroutine: AsyncIOScheduler runs sync jobs in a worker thread, + # where we can't touch the MQTT publisher or the polling state safely. + self.set_refresh_mode(RefreshMode.FORCE, "check for scheduled charging start") + def set_refresh_mode(self, mode: RefreshMode, cause: str) -> None: if mode is not None and ( self.refresh_mode is None or self.refresh_mode != mode diff --git a/tests/handlers/command/test_drivetrain_charging_cable_lock.py b/tests/handlers/command/test_drivetrain_charging_cable_lock.py new file mode 100644 index 00000000..6dab3334 --- /dev/null +++ b/tests/handlers/command/test_drivetrain_charging_cable_lock.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from handlers.command.base import RESULT_REFRESH_AND_CLEAR +from handlers.command.drivetrain.drivetrain_charging_cable_lock import ( + DrivetrainChargingCableLockCommand, +) + + +class TestDrivetrainChargingCableLockCommand(unittest.IsolatedAsyncioTestCase): + async def test_lock_publishes_locked_state(self) -> None: + vehicle_state = MagicMock() + saic_api = AsyncMock() + handler = DrivetrainChargingCableLockCommand(saic_api, vehicle_state) + + result = await handler.handle("true") + + assert result == RESULT_REFRESH_AND_CLEAR + saic_api.control_charging_port_lock.assert_called_once_with( + vehicle_state.vin, unlock=False + ) + vehicle_state.update_charging_cable_lock.assert_called_once_with(locked=True) + + async def test_unlock_publishes_unlocked_state(self) -> None: + vehicle_state = MagicMock() + saic_api = AsyncMock() + handler = DrivetrainChargingCableLockCommand(saic_api, vehicle_state) + + await handler.handle("false") + + saic_api.control_charging_port_lock.assert_called_once_with( + vehicle_state.vin, unlock=True + ) + vehicle_state.update_charging_cable_lock.assert_called_once_with(locked=False) + + async def test_failed_command_does_not_publish_state(self) -> None: + vehicle_state = MagicMock() + saic_api = AsyncMock() + saic_api.control_charging_port_lock.side_effect = RuntimeError("boom") + handler = DrivetrainChargingCableLockCommand(saic_api, vehicle_state) + + with pytest.raises(RuntimeError): + await handler.handle("true") + + vehicle_state.update_charging_cable_lock.assert_not_called() diff --git a/tests/handlers/test_vehicle_command.py b/tests/handlers/test_vehicle_command.py index 4815b5ec..1d04ede1 100644 --- a/tests/handlers/test_vehicle_command.py +++ b/tests/handlers/test_vehicle_command.py @@ -55,7 +55,7 @@ async def test_successful_command_publishes_success(self) -> None: await handler.handle_mqtt_command(topic=CHARGING_SET_TOPIC, payload="true") - pub.publish_str.assert_any_call(CHARGING_RESULT_TOPIC, "Success") + pub.publish_str.assert_any_call(CHARGING_RESULT_TOPIC, "Success", retain=False) pub.publish_json.assert_not_called() @@ -70,6 +70,7 @@ async def test_publishes_error_event(self) -> None: pub.publish_str.assert_any_call( result_topic, "Failed: No handler found for command topic nonexistent/topic/set", + retain=False, ) pub.publish_json.assert_called_once() event = pub.publish_json.call_args[0][1] @@ -99,6 +100,7 @@ async def test_publishes_error_event(self) -> None: CHARGING_RESULT_TOPIC, "Failed: Unsupported payload not_a_boolean for command " "DrivetrainChargingCommand", + retain=False, ) pub.publish_json.assert_called_once() event = pub.publish_json.call_args[0][1] @@ -119,6 +121,7 @@ async def test_publishes_error_event(self) -> None: pub.publish_str.assert_any_call( CHARGING_RESULT_TOPIC, "Failed: return code: 8, message: operation too frequent", + retain=False, ) pub.publish_json.assert_called_once() event = pub.publish_json.call_args[0][1] @@ -135,7 +138,7 @@ async def test_uses_safe_detail(self) -> None: await handler.handle_mqtt_command(topic=CHARGING_SET_TOPIC, payload="true") pub.publish_str.assert_any_call( - CHARGING_RESULT_TOPIC, "Failed: unexpected error" + CHARGING_RESULT_TOPIC, "Failed: unexpected error", retain=False ) event = pub.publish_json.call_args[0][1] assert event["detail"] == "unexpected error" @@ -157,7 +160,7 @@ async def test_relogin_success_retries_command(self) -> None: assert isinstance(relogin, AsyncMock) relogin.force_login.assert_awaited_once() assert saic_api.control_charging.await_count == 2 - pub.publish_str.assert_any_call(CHARGING_RESULT_TOPIC, "Success") + pub.publish_str.assert_any_call(CHARGING_RESULT_TOPIC, "Success", retain=False) pub.publish_json.assert_not_called() async def test_relogin_failure_publishes_error_event(self) -> None: @@ -170,7 +173,7 @@ async def test_relogin_failure_publishes_error_event(self) -> None: await handler.handle_mqtt_command(topic=CHARGING_SET_TOPIC, payload="true") pub.publish_str.assert_any_call( - CHARGING_RESULT_TOPIC, "Failed: relogin failed (login failed)" + CHARGING_RESULT_TOPIC, "Failed: relogin failed (login failed)", retain=False ) pub.publish_json.assert_called_once() event = pub.publish_json.call_args[0][1] @@ -186,7 +189,9 @@ async def test_retry_failure_publishes_error_event(self) -> None: await handler.handle_mqtt_command(topic=CHARGING_SET_TOPIC, payload="true") - pub.publish_str.assert_any_call(CHARGING_RESULT_TOPIC, "Failed: retry boom") + pub.publish_str.assert_any_call( + CHARGING_RESULT_TOPIC, "Failed: retry boom", retain=False + ) pub.publish_json.assert_called_once() event = pub.publish_json.call_args[0][1] assert event["detail"] == "retry boom" @@ -277,7 +282,9 @@ async def test_retained_force_refresh_mode_dropped(self) -> None: ) vehicle_state.set_refresh_mode.assert_not_called() - pub.publish_str.assert_any_call(REFRESH_MODE_RESULT_TOPIC, "Success") + pub.publish_str.assert_any_call( + REFRESH_MODE_RESULT_TOPIC, "Success", retain=False + ) async def test_retained_charging_detection_refresh_mode_dropped(self) -> None: handler, pub = _build() @@ -290,7 +297,9 @@ async def test_retained_charging_detection_refresh_mode_dropped(self) -> None: ) vehicle_state.set_refresh_mode.assert_not_called() - pub.publish_str.assert_any_call(REFRESH_MODE_RESULT_TOPIC, "Success") + pub.publish_str.assert_any_call( + REFRESH_MODE_RESULT_TOPIC, "Success", retain=False + ) async def test_retained_periodic_refresh_mode_applied(self) -> None: handler, _pub = _build() @@ -339,7 +348,9 @@ async def test_retained_battery_capacity_replays_to_vehicle_info(self) -> None: vehicle_state.update_battery_capacity.assert_called_once_with(50.0) pub.publish_float.assert_any_call(TOTAL_BATTERY_CAPACITY_STATE_TOPIC, 50.0) - pub.publish_str.assert_any_call(TOTAL_BATTERY_CAPACITY_RESULT_TOPIC, "Success") + pub.publish_str.assert_any_call( + TOTAL_BATTERY_CAPACITY_RESULT_TOPIC, "Success", retain=False + ) async def test_battery_capacity_zero_payload_publishes_model_default(self) -> None: """Payload `0` clears the override; the per-model default is republished.""" diff --git a/tests/integrations/abrp/test_abrp_api.py b/tests/integrations/abrp/test_abrp_api.py index fbc1feb4..eb1e9145 100644 --- a/tests/integrations/abrp/test_abrp_api.py +++ b/tests/integrations/abrp/test_abrp_api.py @@ -105,3 +105,32 @@ async def test_skips_when_missing_config( ) assert not success await api.close() + + +@pytest.mark.asyncio +async def test_skip_reason_lists_missing_config( + vehicle_status: VehicleStatusResp, + charge_info: ChrgMgmtDataResp, +) -> None: + api = AbrpApi(abrp_api_key=None, abrp_user_token=None) + _success, response = await api.update_abrp( + vehicle_status=vehicle_status, + charge_info=charge_info, + ) + assert "ABRP API key" in response + assert "ABRP user token" in response + await api.close() + + +@pytest.mark.asyncio +async def test_skip_reason_reports_missing_charge_data( + abrp_api: AbrpApi, + vehicle_status: VehicleStatusResp, +) -> None: + success, response = await abrp_api.update_abrp( + vehicle_status=vehicle_status, + charge_info=None, + ) + assert not success + assert "charge management data" in response + assert "token" not in response diff --git a/tests/integrations/home_assistant/test_discovery_retain.py b/tests/integrations/home_assistant/test_discovery_retain.py index 45fa7898..f56731fb 100644 --- a/tests/integrations/home_assistant/test_discovery_retain.py +++ b/tests/integrations/home_assistant/test_discovery_retain.py @@ -104,7 +104,7 @@ def test_required_entities_have_retain_true(self) -> None: assert payload is not None, ( f"No writable HA discovery payload found for topic {topic}" ) - assert payload.get("retain") == "true", ( + assert payload.get("retain") is True, ( f"Expected retain=true for {topic}, got {payload.get('retain')!r}" ) @@ -117,6 +117,6 @@ def test_non_retained_entities_keep_retain_false(self) -> None: payload = _payload_for_state_topic_suffix(payloads, topic) if payload is None: continue # entity not published for this vehicle config - assert payload.get("retain") in ("false", None), ( + assert payload.get("retain") in (False, None), ( f"Expected retain!=true for {topic}, got {payload.get('retain')!r}" ) diff --git a/tests/publisher/test_publish_dispatch.py b/tests/publisher/test_publish_dispatch.py index 7902771a..9c8c4242 100644 --- a/tests/publisher/test_publish_dispatch.py +++ b/tests/publisher/test_publish_dispatch.py @@ -283,6 +283,10 @@ def __init__(self, config: Configuration) -> None: async def connect(self) -> None: pass + @override + async def flush(self) -> None: + pass + @override def enable_commands(self) -> None: pass @@ -299,35 +303,60 @@ def publish_json( no_prefix: bool = False, *, retain: bool = True, + qos: int = 0, ) -> None: pass @override def publish_str( - self, key: str, value: str, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: str, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: pass @override def publish_int( - self, key: str, value: int, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: int, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: pass @override def publish_bool( - self, key: str, value: bool, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: bool, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: pass @override def publish_float( - self, key: str, value: float, no_prefix: bool = False, *, retain: bool = True + self, + key: str, + value: float, + no_prefix: bool = False, + *, + retain: bool = True, + qos: int = 0, ) -> None: pass @override - def clear_topic(self, key: str, no_prefix: bool = False) -> None: + def clear_topic(self, key: str, no_prefix: bool = False, qos: int = 0) -> None: pass diff --git a/tests/test_background_tasks.py b/tests/test_background_tasks.py new file mode 100644 index 00000000..e5dbe534 --- /dev/null +++ b/tests/test_background_tasks.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import gc +import logging +import unittest + +import pytest + +from background_tasks import BackgroundTasks, log_task_failure + + +class TestBackgroundTasks(unittest.IsolatedAsyncioTestCase): + async def test_task_is_held_until_done_then_released(self) -> None: + tasks = BackgroundTasks() + release = asyncio.Event() + + async def work() -> str: + await release.wait() + return "done" + + task = tasks.spawn(work()) + assert len(tasks) == 1 + release.set() + assert await task == "done" + await tasks.wait() + assert len(tasks) == 0 + + async def test_wait_returns_once_tasks_are_done_and_released(self) -> None: + tasks = BackgroundTasks() + tasks.spawn(asyncio.sleep(0.01)) + tasks.spawn(asyncio.sleep(0.02)) + + await tasks.wait() + + assert len(tasks) == 0 + + async def test_bounded_wait_does_not_cancel_tasks(self) -> None: + tasks = BackgroundTasks() + task = tasks.spawn(asyncio.sleep(10)) + + with pytest.raises(TimeoutError): + await asyncio.wait_for(tasks.wait(), timeout=0.01) + + assert not task.done() + assert len(tasks) == 1 + task.cancel() + + async def test_task_survives_garbage_collection(self) -> None: + tasks = BackgroundTasks() + finished = asyncio.Event() + + async def work() -> None: + await asyncio.sleep(0.01) + finished.set() + + tasks.spawn(work()) + gc.collect() + await asyncio.wait_for(finished.wait(), 1) + + async def test_failure_is_logged_and_task_released(self) -> None: + tasks = BackgroundTasks() + + async def boom() -> None: + msg = "kaput" + raise ValueError(msg) + + with self.assertLogs("background_tasks", level=logging.ERROR) as logs: + task = tasks.spawn(boom(), name="boom-task") + await asyncio.wait([task]) + await tasks.wait() + + assert "boom-task" in logs.output[0] + assert len(tasks) == 0 + + async def test_cancelled_task_is_released_without_error_log(self) -> None: + tasks = BackgroundTasks() + task = tasks.spawn(asyncio.sleep(10)) + task.cancel() + with self.assertNoLogs("background_tasks", level=logging.ERROR): + await asyncio.wait([task]) + await tasks.wait() + assert len(tasks) == 0 + + async def test_spawn_outside_event_loop_raises(self) -> None: + tasks = BackgroundTasks() + + async def work() -> None: + return None + + with pytest.raises(RuntimeError, match="outside the event loop thread"): + await asyncio.to_thread(lambda: tasks.spawn(work())) + + +class TestLogTaskFailure(unittest.IsolatedAsyncioTestCase): + async def test_logs_exception_of_failed_task(self) -> None: + async def boom() -> None: + msg = "kaput" + raise ValueError(msg) + + task = asyncio.get_running_loop().create_task(boom(), name="boom-task") + task.add_done_callback(log_task_failure) + with self.assertLogs("background_tasks", level=logging.ERROR) as logs: + await asyncio.wait([task]) + + assert "boom-task" in logs.output[0] diff --git a/tests/test_gateway_shutdown.py b/tests/test_gateway_shutdown.py new file mode 100644 index 00000000..a71a938b --- /dev/null +++ b/tests/test_gateway_shutdown.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import asyncio +from typing import Any +import unittest +from unittest.mock import AsyncMock, patch + +import pytest + +from configuration import Configuration +from mqtt_gateway import MqttGateway + +from .mocks import MessageCapturingConsolePublisher + + +def _make_gateway() -> MqttGateway: + config = Configuration() + config.saic_user = "user@example.com" + config.saic_password = "secret" # noqa: S105 + with patch( + "mqtt_gateway.MqttGateway._MqttGateway__select_publisher", + return_value=MessageCapturingConsolePublisher(config), + ): + return MqttGateway(config) + + +class TestGatewayShutdown(unittest.IsolatedAsyncioTestCase): + async def _run_with_main_loop( + self, gateway: MqttGateway, main_loop: AsyncMock + ) -> None: + with ( + patch.object(gateway, "_MqttGateway__do_initial_login", AsyncMock()), + patch.object(gateway, "_MqttGateway__run_until_all_tasks_done", main_loop), + patch.object(gateway, "_MqttGateway__scheduler", create=True), + ): + await gateway.run() + + async def test_api_client_closed_when_main_loop_exits(self) -> None: + gateway = _make_gateway() + gateway.saic_api.close = AsyncMock() # type: ignore[method-assign] + + with pytest.raises(SystemExit): + await self._run_with_main_loop( + gateway, AsyncMock(side_effect=SystemExit(-1)) + ) + + gateway.saic_api.close.assert_awaited_once() + + async def test_close_failure_is_logged_not_raised(self) -> None: + gateway = _make_gateway() + gateway.saic_api.close = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + + with self.assertLogs("mqtt_gateway", level="WARNING"): + await self._run_with_main_loop(gateway, AsyncMock()) + + gateway.saic_api.close.assert_awaited_once() + + async def test_publisher_flushed_before_api_client_closes(self) -> None: + gateway = _make_gateway() + order: list[str] = [] + gateway.saic_api.close = AsyncMock( # type: ignore[method-assign] + side_effect=lambda: order.append("close") + ) + gateway.publisher.flush = AsyncMock( # type: ignore[method-assign] + side_effect=lambda: order.append("flush") + ) + + await self._run_with_main_loop(gateway, AsyncMock()) + + assert order == ["flush", "close"] + + async def test_flush_failure_is_logged_not_raised(self) -> None: + gateway = _make_gateway() + gateway.saic_api.close = AsyncMock() # type: ignore[method-assign] + gateway.publisher.flush = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + + with self.assertLogs("mqtt_gateway", level="WARNING"): + await self._run_with_main_loop(gateway, AsyncMock()) + + gateway.saic_api.close.assert_awaited_once() + + async def _cancel_while_blocked_in( + self, gateway: MqttGateway, blocked_on: str, flush: AsyncMock + ) -> AsyncMock: + started = asyncio.Event() + + async def block(*_: Any, **__: Any) -> None: + started.set() + await asyncio.Event().wait() + + close = AsyncMock() + gateway.saic_api.close = close # type: ignore[method-assign] + gateway.publisher.flush = flush # type: ignore[method-assign] + with ( + patch.object(gateway, "_MqttGateway__do_initial_login", AsyncMock()), + patch.object( + gateway, "_MqttGateway__run_until_all_tasks_done", AsyncMock() + ), + patch.object(gateway, "_MqttGateway__scheduler", create=True), + patch.object(gateway, blocked_on, side_effect=block), + ): + task = asyncio.get_running_loop().create_task(gateway.run()) + await asyncio.wait_for(started.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return close + + async def test_cancel_during_startup_still_flushes_and_closes(self) -> None: + gateway = _make_gateway() + flush = AsyncMock() + + close = await self._cancel_while_blocked_in( + gateway, "_MqttGateway__do_initial_login", flush + ) + + flush.assert_awaited_once() + close.assert_awaited_once() + + async def test_second_cancel_during_flush_still_closes(self) -> None: + gateway = _make_gateway() + flushing = asyncio.Event() + + async def slow_flush() -> None: + flushing.set() + await asyncio.Event().wait() + + gateway.saic_api.close = close = AsyncMock() # type: ignore[method-assign] + gateway.publisher.flush = slow_flush # type: ignore[method-assign] + with ( + patch.object(gateway, "_MqttGateway__do_initial_login", AsyncMock()), + patch.object( + gateway, + "_MqttGateway__run_until_all_tasks_done", + AsyncMock(side_effect=SystemExit(-1)), + ), + patch.object(gateway, "_MqttGateway__scheduler", create=True), + ): + task = asyncio.get_running_loop().create_task(gateway.run()) + await asyncio.wait_for(flushing.wait(), timeout=1) + task.cancel() # a further SIGTERM while the flush is waiting + with pytest.raises(asyncio.CancelledError): + await task + + close.assert_awaited_once() diff --git a/tests/test_gateway_supervision.py b/tests/test_gateway_supervision.py new file mode 100644 index 00000000..0dd28b9d --- /dev/null +++ b/tests/test_gateway_supervision.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from typing import Any +import unittest +from unittest.mock import patch + +import pytest + +from configuration import Configuration +from mqtt_gateway import MqttGateway + +from .mocks import MessageCapturingConsolePublisher + + +def _make_gateway() -> MqttGateway: + config = Configuration() + config.saic_user = "user@example.com" + config.saic_password = "secret" # noqa: S105 + with patch( + "mqtt_gateway.MqttGateway._MqttGateway__select_publisher", + return_value=MessageCapturingConsolePublisher(config), + ): + return MqttGateway(config) + + +async def _supervise( + gateway: MqttGateway, transport_task: asyncio.Task[Any] | None +) -> None: + run = gateway._MqttGateway__run_until_all_tasks_done # type: ignore[attr-defined] + with patch.object( + gateway.publisher, "connection_task", return_value=transport_task + ): + await asyncio.wait_for(run(), timeout=0.2) + + +class TestTransportSupervision(unittest.IsolatedAsyncioTestCase): + async def test_exits_when_transport_task_crashes(self) -> None: + async def crash() -> None: + msg = "boom" + raise RuntimeError(msg) + + task = asyncio.get_running_loop().create_task(crash()) + + with self.assertLogs("mqtt_gateway", level="ERROR"), pytest.raises(SystemExit): + await _supervise(_make_gateway(), task) + + async def test_exits_when_transport_task_ends_cleanly(self) -> None: + async def finish() -> None: + return None + + task = asyncio.get_running_loop().create_task(finish()) + + with self.assertLogs("mqtt_gateway", level="ERROR"), pytest.raises(SystemExit): + await _supervise(_make_gateway(), task) + + async def test_keeps_running_while_transport_task_is_alive(self) -> None: + task = asyncio.get_running_loop().create_task(asyncio.sleep(10)) + + with pytest.raises(TimeoutError): + await _supervise(_make_gateway(), task) + + task.cancel() + with suppress(asyncio.CancelledError): + await task + + async def test_keeps_running_without_a_transport(self) -> None: + """MQTT can be disabled (e.g. to only feed ABRP): nothing to supervise.""" + with pytest.raises(TimeoutError): + await _supervise(_make_gateway(), None) + + async def test_default_publisher_has_no_connection_task(self) -> None: + assert _make_gateway().publisher.connection_task() is None diff --git a/tests/test_ha_discovery_windows.py b/tests/test_ha_discovery_windows.py index cff3e6d2..404438ce 100644 --- a/tests/test_ha_discovery_windows.py +++ b/tests/test_ha_discovery_windows.py @@ -11,6 +11,7 @@ from configuration import Configuration from integrations.home_assistant.discovery import HomeAssistantDiscovery +import mqtt_topics from vehicle import RefreshMode, VehicleState from vehicle_info import VehicleInfo @@ -110,3 +111,35 @@ def test_sunroof_unpublished_when_not_supported(self) -> None: "Expected unpublish message for binary_sensor Sun roof" ) assert publisher.map[sunroof_binary] == "" + + +class TestHaDiscoveryChargingAvailability(unittest.TestCase): + """Charging entities must go unavailable when charge data is stale.""" + + def test_charging_entities_depend_on_charge_data_availability(self) -> None: + discovery, publisher = TestHaDiscoveryWindows()._make_discovery() + discovery.publish_ha_discovery_messages() + + availability_topic = ( + f"/vehicles/{VIN}/{mqtt_topics.DRIVETRAIN_CHARGING_DATA_AVAILABLE}" + ) + topics = [ + f"homeassistant/binary_sensor/{VIN}_mg/{VIN}_battery_charging/config", + f"homeassistant/sensor/{VIN}_mg/{VIN}_remaining_charging_time/config", + ] + for topic in topics: + assert topic in publisher.map, topic + payload = json.loads(publisher.map[topic]) + assert availability_topic in {a["topic"] for a in payload["availability"]} + + def test_charging_switch_stays_available_without_charge_data(self) -> None: + discovery, publisher = TestHaDiscoveryWindows()._make_discovery() + discovery.publish_ha_discovery_messages() + + availability_topic = ( + f"/vehicles/{VIN}/{mqtt_topics.DRIVETRAIN_CHARGING_DATA_AVAILABLE}" + ) + payload = json.loads( + publisher.map[f"homeassistant/switch/{VIN}_mg/{VIN}_charging/config"] + ) + assert availability_topic not in {a["topic"] for a in payload["availability"]} diff --git a/tests/test_mqtt_publisher.py b/tests/test_mqtt_publisher.py index 1fdeba34..db2d839f 100644 --- a/tests/test_mqtt_publisher.py +++ b/tests/test_mqtt_publisher.py @@ -1,8 +1,13 @@ from __future__ import annotations +import asyncio +from contextlib import suppress +import json from typing import Any, override import unittest -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from configuration import Configuration, TransportProtocol from publisher.core import MqttCommandListener @@ -71,7 +76,7 @@ async def test_update_rear_window_heat_state(self) -> None: assert self.received_payload == REAR_WINDOW_HEAT_STATE async def send_message(self, topic: str, payload: Any) -> None: - await self.mqtt_client.client.on_message("client", topic, payload, 0, {}) + await self.mqtt_client._on_message("client", topic, payload, 0, False) async def test_get_vin_from_sanitized_topic(self) -> None: """Topics arrive with the sanitized prefix, not the raw username.""" @@ -100,63 +105,217 @@ async def on_charger_connection_state_changed( ) -> None: pass - def test_publish_str_default_is_retained(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: + async def _call_and_flush(self, fn: Any, *args: Any, **kwargs: Any) -> AsyncMock: + mock = AsyncMock() + with patch.object(self.mqtt_client, "_MqttPublisher__async_publish", mock): + self.mqtt_client._MqttPublisher__connected.set() # type: ignore[attr-defined] + fn(*args, **kwargs) + await asyncio.sleep(0) + return mock + + async def test_publish_str_default_is_retained(self) -> None: + m = await self._call_and_flush(self.mqtt_client.publish_str, "foo", "bar") + m.assert_called_once_with("saic/foo", "bar", retain=True, qos=0) + + async def test_publish_runs_as_background_task(self) -> None: + pending = self.mqtt_client._MqttPublisher__background_tasks # type: ignore[attr-defined] + with patch.object( + self.mqtt_client, "_MqttPublisher__async_publish", AsyncMock() + ) as publish: self.mqtt_client.publish_str("foo", "bar") - m_pub.assert_called_once_with("saic/foo", "bar", retain=True) - - def test_publish_str_forwards_retain_false(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_str("foo", "bar", retain=False) - m_pub.assert_called_once_with("saic/foo", "bar", retain=False) - - def test_publish_int_default_is_retained(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_int("foo", 42) - m_pub.assert_called_once_with("saic/foo", 42, retain=True) - - def test_publish_int_forwards_retain_false(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_int("foo", 42, retain=False) - m_pub.assert_called_once_with("saic/foo", 42, retain=False) - - def test_publish_bool_default_is_retained(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_bool("foo", True) - m_pub.assert_called_once_with("saic/foo", True, retain=True) - - def test_publish_bool_forwards_retain_false(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_bool("foo", True, retain=False) - m_pub.assert_called_once_with("saic/foo", True, retain=False) - - def test_publish_float_default_is_retained(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_float("foo", 1.5) - m_pub.assert_called_once_with("saic/foo", 1.5, retain=True) - - def test_publish_float_forwards_retain_false(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_float("foo", 1.5, retain=False) - m_pub.assert_called_once_with("saic/foo", 1.5, retain=False) - - def test_publish_json_default_is_retained(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_json("foo", {"a": 1}) - m_pub.assert_called_once() - args, kwargs = m_pub.call_args - assert args[0] == "saic/foo" - assert kwargs == {"retain": True} - - def test_publish_json_forwards_retain_false(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.publish_json("foo", {"a": 1}, retain=False) - m_pub.assert_called_once() - args, kwargs = m_pub.call_args - assert args[0] == "saic/foo" - assert kwargs == {"retain": False} - - def test_clear_topic_publishes_none_retained(self) -> None: - with patch.object(self.mqtt_client.client, "publish") as m_pub: - self.mqtt_client.clear_topic("foo") - m_pub.assert_called_once_with("saic/foo", None, retain=True) + assert len(pending) == 1 + await pending.wait() + publish.assert_awaited_once() + assert len(pending) == 0 + + async def test_enable_commands_runs_as_background_task(self) -> None: + pending = self.mqtt_client._MqttPublisher__background_tasks # type: ignore[attr-defined] + self.mqtt_client.enable_commands() + assert len(pending) == 1 + await pending.wait() + assert len(pending) == 0 + + async def test_publish_str_outside_event_loop_raises_clear_error(self) -> None: + with pytest.raises(RuntimeError, match="outside the event loop thread"): + await asyncio.to_thread(self.mqtt_client.publish_str, "foo", "bar") + + async def test_publish_str_forwards_retain_false(self) -> None: + m = await self._call_and_flush( + self.mqtt_client.publish_str, "foo", "bar", retain=False + ) + m.assert_called_once_with("saic/foo", "bar", retain=False, qos=0) + + async def test_publish_int_default_is_retained(self) -> None: + m = await self._call_and_flush(self.mqtt_client.publish_int, "foo", 42) + m.assert_called_once_with("saic/foo", 42, retain=True, qos=0) + + async def test_publish_int_forwards_retain_false(self) -> None: + m = await self._call_and_flush( + self.mqtt_client.publish_int, "foo", 42, retain=False + ) + m.assert_called_once_with("saic/foo", 42, retain=False, qos=0) + + async def test_publish_bool_default_is_retained(self) -> None: + m = await self._call_and_flush(self.mqtt_client.publish_bool, "foo", True) + m.assert_called_once_with("saic/foo", True, retain=True, qos=0) + + async def test_publish_bool_forwards_retain_false(self) -> None: + m = await self._call_and_flush( + self.mqtt_client.publish_bool, "foo", True, retain=False + ) + m.assert_called_once_with("saic/foo", True, retain=False, qos=0) + + async def test_publish_float_default_is_retained(self) -> None: + m = await self._call_and_flush(self.mqtt_client.publish_float, "foo", 1.5) + m.assert_called_once_with("saic/foo", 1.5, retain=True, qos=0) + + async def test_publish_float_forwards_retain_false(self) -> None: + m = await self._call_and_flush( + self.mqtt_client.publish_float, "foo", 1.5, retain=False + ) + m.assert_called_once_with("saic/foo", 1.5, retain=False, qos=0) + + async def test_publish_json_default_is_retained(self) -> None: + m = await self._call_and_flush(self.mqtt_client.publish_json, "foo", {"a": 1}) + m.assert_called_once() + args, kwargs = m.call_args + assert args[0] == "saic/foo" + assert json.loads(args[1]) == {"a": 1} + assert kwargs == {"retain": True, "qos": 0} + + async def test_publish_json_forwards_retain_false(self) -> None: + m = await self._call_and_flush( + self.mqtt_client.publish_json, "foo", {"a": 1}, retain=False + ) + m.assert_called_once() + args, kwargs = m.call_args + assert args[0] == "saic/foo" + assert json.loads(args[1]) == {"a": 1} + assert kwargs == {"retain": False, "qos": 0} + + async def test_clear_topic_publishes_none_retained(self) -> None: + m = await self._call_and_flush(self.mqtt_client.clear_topic, "foo") + m.assert_called_once_with("saic/foo", None, retain=True, qos=0) + + async def _connect_and_capture_client_kwargs( + self, config: Configuration + ) -> dict[str, Any]: + publisher = MqttPublisher(config) + with patch("publisher.mqtt_publisher.aiomqtt.Client") as client_cls: + client_cls.return_value.__aenter__.side_effect = asyncio.CancelledError + connect_task = asyncio.get_running_loop().create_task(publisher.connect()) + for _ in range(20): + await asyncio.sleep(0) + if client_cls.call_args is not None: + break + connect_task.cancel() + with suppress(asyncio.CancelledError): + await connect_task + assert client_cls.call_args is not None + return dict(client_cls.call_args.kwargs) + + async def test_connect_raises_when_loop_crashes_before_connecting(self) -> None: + """An unexpected error in the MQTT loop must not hang connect() forever.""" + config = Configuration() + config.mqtt_host = "localhost" + config.mqtt_transport_protocol = TransportProtocol.TCP + publisher = MqttPublisher(config) + with patch("publisher.mqtt_publisher.aiomqtt.Client") as client_cls: + client_cls.return_value.__aenter__.side_effect = RuntimeError("boom") + with ( + self.assertLogs("background_tasks", level="ERROR") as logs, + pytest.raises(RuntimeError, match="boom"), + ): + await asyncio.wait_for(publisher.connect(), timeout=2) + assert "mqtt-loop" in logs.output[0] + + async def test_connection_task_is_the_mqtt_loop_task(self) -> None: + config = Configuration() + config.mqtt_host = "localhost" + config.mqtt_transport_protocol = TransportProtocol.TCP + publisher = MqttPublisher(config) + assert publisher.connection_task() is None + with patch("publisher.mqtt_publisher.aiomqtt.Client") as client_cls: + client_cls.return_value.__aenter__.side_effect = asyncio.CancelledError + connect_task = asyncio.get_running_loop().create_task(publisher.connect()) + await asyncio.sleep(0) + task = publisher.connection_task() + connect_task.cancel() + with suppress(asyncio.CancelledError): + await connect_task + assert task is not None + assert task.get_name() == "mqtt-loop" + + def _connected_publisher_with_client(self, client: Any) -> MqttPublisher: + publisher = MqttPublisher(Configuration()) + publisher.client = client + publisher._MqttPublisher__connected.set() # type: ignore[attr-defined] + return publisher + + async def test_flush_waits_for_in_flight_publishes(self) -> None: + sent: list[str] = [] + + class SlowClient: + async def publish(self, topic: str, *_: Any, **__: Any) -> None: + await asyncio.sleep(0.05) + sent.append(topic) + + publisher = self._connected_publisher_with_client(SlowClient()) + publisher.publish_str("last/words", "bye") + assert sent == [] + + await publisher.flush() + + assert sent == ["saic/last/words"] + + async def test_flush_gives_up_after_timeout(self) -> None: + release = asyncio.Event() + sent: list[str] = [] + + class StuckClient: + async def publish(self, topic: str, *_: Any, **__: Any) -> None: + await release.wait() + sent.append(topic) + + publisher = self._connected_publisher_with_client(StuckClient()) + publisher.publish_str("stuck", "x") + + with ( + patch("publisher.mqtt_publisher._FLUSH_TIMEOUT", 0.05), + self.assertLogs("publisher.mqtt_publisher", level="WARNING"), + ): + await publisher.flush() + assert sent == [] + + # The publish was not cancelled: it still completes once unblocked + release.set() + await publisher.flush() + assert sent == ["saic/stuck"] + + async def test_flush_without_pending_publishes_returns_immediately(self) -> None: + await self.mqtt_client.flush() + + async def test_plain_tcp_leaves_tls_insecure_unset(self) -> None: + """tls_insecure must stay None without TLS, else paho raises ValueError.""" + config = Configuration() + config.mqtt_host = "localhost" + config.mqtt_transport_protocol = TransportProtocol.TCP + kwargs = await self._connect_and_capture_client_kwargs(config) + assert kwargs["tls_insecure"] is None + assert kwargs["tls_context"] is None + + async def test_tls_without_hostname_check_enables_tls_insecure(self) -> None: + config = Configuration() + config.mqtt_host = "localhost" + config.mqtt_transport_protocol = TransportProtocol.TLS + config.tls_server_cert_check_hostname = False + kwargs = await self._connect_and_capture_client_kwargs(config) + assert kwargs["tls_insecure"] is True + assert kwargs["tls_context"] is not None + + async def test_tls_with_hostname_check_leaves_tls_insecure_unset(self) -> None: + config = Configuration() + config.mqtt_host = "localhost" + config.mqtt_transport_protocol = TransportProtocol.TLS + kwargs = await self._connect_and_capture_client_kwargs(config) + assert kwargs["tls_insecure"] is None diff --git a/tests/test_scheduler_jobs.py b/tests/test_scheduler_jobs.py new file mode 100644 index 00000000..5a3a0a80 --- /dev/null +++ b/tests/test_scheduler_jobs.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import datetime +import inspect +from typing import TYPE_CHECKING +import unittest +from unittest.mock import AsyncMock, patch + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from saic_ismart_client_ng.api.vehicle.schema import VinInfo +from saic_ismart_client_ng.api.vehicle_charging import ScheduledChargingMode + +from configuration import Configuration +from mqtt_gateway import MqttGateway +from vehicle import VehicleState +from vehicle_info import VehicleInfo + +from .common_mocks import VIN +from .mocks import MessageCapturingConsolePublisher + +if TYPE_CHECKING: + from apscheduler.job import Job + + +class TestSchedulerJobsRunOnEventLoop(unittest.IsolatedAsyncioTestCase): + """Every scheduled job must be a coroutine function. + + AsyncIOScheduler runs synchronous jobs in a worker thread, where the MQTT + publisher (bound to the event loop) and the polling state are not safe to use. + """ + + @staticmethod + def _assert_all_coroutines(jobs: list[Job]) -> None: + assert jobs, "expected at least one scheduled job" + sync_jobs = [j.id for j in jobs if not inspect.iscoroutinefunction(j.func)] + assert not sync_jobs, f"jobs would run in a worker thread: {sync_jobs}" + + async def test_all_gateway_jobs_are_coroutines(self) -> None: + config = Configuration() + config.saic_user = "user@example.com" + config.saic_password = "secret" # noqa: S105 + with patch( + "mqtt_gateway.MqttGateway._MqttGateway__select_publisher", + return_value=MessageCapturingConsolePublisher(config), + ): + gateway = MqttGateway(config) + gateway.saic_api.close = AsyncMock() # type: ignore[method-assign] + scheduler: AsyncIOScheduler = gateway._MqttGateway__scheduler # type: ignore[attr-defined] + relogin_handler = gateway._MqttGateway__relogin_handler # type: ignore[attr-defined] + + with ( + patch.object(gateway, "_MqttGateway__do_initial_login", AsyncMock()), + patch.object( + gateway, "_MqttGateway__run_until_all_tasks_done", AsyncMock() + ), + patch.object(scheduler, "start"), + ): + await gateway.run() + relogin_handler.relogin() + + self._assert_all_coroutines(scheduler.get_jobs()) + + async def test_vehicle_jobs_are_coroutines(self) -> None: + scheduler = AsyncIOScheduler() + vin_info = VinInfo() + vin_info.vin = VIN + state = VehicleState( + MessageCapturingConsolePublisher(Configuration()), + scheduler, + f"/vehicles/{VIN}", + VehicleInfo(vin_info, None), + ) + state.update_scheduled_charging( + datetime.time(hour=23, minute=10), + ScheduledChargingMode.UNTIL_CONFIGURED_TIME, + ) + + self._assert_all_coroutines(scheduler.get_jobs()) diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py new file mode 100644 index 00000000..bc90f3bf --- /dev/null +++ b/tests/test_shutdown.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import asyncio +import os +import signal +import sys +import unittest + +import pytest + +from shutdown import run_until_terminated + + +@pytest.mark.skipif(sys.platform == "win32", reason="needs POSIX signals") +class TestRunUntilTerminated(unittest.IsolatedAsyncioTestCase): + async def test_sigterm_cancels_main_and_runs_cleanup(self) -> None: + cleaned_up = False + + async def main() -> None: + nonlocal cleaned_up + try: + await asyncio.sleep(30) + finally: + cleaned_up = True + + loop = asyncio.get_running_loop() + loop.call_later(0.05, os.kill, os.getpid(), signal.SIGTERM) + + await run_until_terminated(main()) + + assert cleaned_up + + async def test_normal_completion_returns(self) -> None: + done = False + + async def main() -> None: + nonlocal done + done = True + + await run_until_terminated(main()) + + assert done + + async def test_exceptions_from_main_propagate(self) -> None: + async def main() -> None: + msg = "boom" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError): + await run_until_terminated(main()) + + async def test_external_cancellation_is_not_swallowed(self) -> None: + async def main() -> None: + await asyncio.sleep(30) + + runner = asyncio.ensure_future(run_until_terminated(main())) + await asyncio.sleep(0.05) + runner.cancel() + + with pytest.raises(asyncio.CancelledError): + await runner + + async def test_sigterm_handler_removed_afterwards(self) -> None: + async def main() -> None: + return None + + await run_until_terminated(main()) + + assert signal.getsignal(signal.SIGTERM) == signal.SIG_DFL diff --git a/tests/test_utils.py b/tests/test_utils.py index fb2ac5c6..ad00cec4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,14 +1,25 @@ from __future__ import annotations import datetime +from importlib.metadata import PackageNotFoundError +import logging from unittest import TestCase +from unittest.mock import patch from zoneinfo import ZoneInfo +import httpx import pytest from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus from saic_ismart_client_ng.api.vehicle import VehicleStatusResp +from saic_ismart_client_ng.exceptions import SaicApiException, SaicApiRetryException -from utils import get_update_timestamp, parse_timezone +from utils import ( + get_gateway_version, + get_update_timestamp, + is_transient_api_error, + log_transient_api_error, + parse_timezone, +) class Test(TestCase): @@ -136,3 +147,75 @@ def test_parses_gmt_negative_offset(self) -> None: def test_rejects_unknown_format(self) -> None: with pytest.raises(ValueError, match="Unrecognized timezone format"): parse_timezone("not-a-timezone") + + +class TestApiTimeoutHelpers: + @staticmethod + def _wrapped(cause: BaseException | None) -> SaicApiException: + exc = SaicApiException("failed", return_code=500) + exc.__cause__ = cause + return exc + + def test_is_transient_api_error_true_for_httpx_timeout_cause(self) -> None: + assert is_transient_api_error(self._wrapped(httpx.ReadTimeout("t"))) + + def test_is_transient_api_error_true_for_retry_exception(self) -> None: + exc = SaicApiRetryException("try again later", event_id="1", return_code=4) + assert is_transient_api_error(exc) + + def test_is_transient_api_error_false_for_other_cause(self) -> None: + assert not is_transient_api_error(self._wrapped(httpx.ConnectError("c"))) + assert not is_transient_api_error(self._wrapped(None)) + + def test_log_transient_api_error_traceback_only_in_debug( + self, caplog: pytest.LogCaptureFixture + ) -> None: + logger = logging.getLogger("test_api_timeout") + exc = self._wrapped(httpx.ReadTimeout("t")) + with caplog.at_level(logging.INFO, logger=logger.name): + log_transient_api_error(logger, "ctx", exc) + assert caplog.records[-1].exc_info is None + with caplog.at_level(logging.DEBUG, logger=logger.name): + log_transient_api_error(logger, "ctx", exc) + assert caplog.records[-1].exc_info is not None + + def test_log_transient_api_error_level( + self, caplog: pytest.LogCaptureFixture + ) -> None: + logger = logging.getLogger("test_api_level") + exc = self._wrapped(httpx.ReadTimeout("t")) + with caplog.at_level(logging.INFO, logger=logger.name): + log_transient_api_error(logger, "ctx", exc) + assert caplog.records[-1].levelno == logging.WARNING + log_transient_api_error(logger, "ctx", exc, level=logging.ERROR) + assert caplog.records[-1].levelno == logging.ERROR + + +class TestGetGatewayVersion: + def test_release_version_env_wins_over_package_metadata( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("RELEASE_VERSION", "1.2.3") + with patch("utils.version", return_value="0.11.0"): + assert get_gateway_version() == "1.2.3" + + def test_falls_back_to_package_metadata( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("RELEASE_VERSION", raising=False) + with patch("utils.version", return_value="0.11.0"): + assert get_gateway_version() == "0.11.0" + + def test_empty_release_version_is_ignored( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("RELEASE_VERSION", "") + with patch("utils.version", return_value="0.11.0"): + assert get_gateway_version() == "0.11.0" + + def test_unknown_without_env_or_metadata( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("RELEASE_VERSION", raising=False) + with patch("utils.version", side_effect=PackageNotFoundError): + assert get_gateway_version() == "unknown" diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py index b2c9d070..0dede398 100644 --- a/tests/test_vehicle_state.py +++ b/tests/test_vehicle_state.py @@ -1,16 +1,19 @@ from __future__ import annotations import datetime +import inspect import json from typing import Any import unittest from zoneinfo import ZoneInfo +from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.blocking import BlockingScheduler import pytest from saic_ismart_client_ng.api.vehicle.schema import VinInfo from saic_ismart_client_ng.api.vehicle_charging import ( ChargeCurrentLimitCode, + ScheduledChargingMode, TargetBatteryCode, ) from saic_ismart_client_ng.api.vehicle_charging.schema import ( @@ -51,6 +54,80 @@ def setUp(self) -> None: self.publisher, scheduler, account_prefix, vehicle_info ) + def _charge_data_availability(self) -> str | None: + topic = f"/vehicles/{VIN}/{mqtt_topics.DRIVETRAIN_CHARGING_DATA_AVAILABLE}" + return self.publisher.map.get(topic) + + def _cable_lock_topic(self) -> str: + return f"/vehicles/{VIN}/{mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK}" + + def test_cable_lock_published_when_reported(self) -> None: + self.vehicle_state.handle_charge_status(get_mock_charge_management_data_resp()) + assert self.publisher.map[self._cable_lock_topic()] is True + + def test_cable_lock_not_published_when_not_reported(self) -> None: + resp = get_mock_charge_management_data_resp() + assert resp.chrgMgmtData is not None + resp.chrgMgmtData.ccuEleccLckCtrlDspCmd = None + self.vehicle_state.handle_charge_status(resp) + assert self._cable_lock_topic() not in self.publisher.map + + def test_update_charging_cable_lock_publishes_commanded_state(self) -> None: + self.vehicle_state.update_charging_cable_lock(locked=True) + assert self.publisher.map[self._cable_lock_topic()] is True + self.vehicle_state.update_charging_cable_lock(locked=False) + assert self.publisher.map[self._cable_lock_topic()] is False + + def _vehicle_availability(self) -> str | None: + return self.publisher.map.get(f"/vehicles/{VIN}/{mqtt_topics.AVAILABLE}") + + def test_vehicle_offline_only_after_repeated_failures(self) -> None: + self.vehicle_state.mark_successful_refresh() + assert self._vehicle_availability() == "online" + + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_failed_refresh() + assert self._vehicle_availability() == "online" + + self.vehicle_state.mark_failed_refresh() + assert self._vehicle_availability() == "offline" + + def test_successful_refresh_resets_vehicle_failure_count(self) -> None: + self.vehicle_state.mark_successful_refresh() + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_successful_refresh() + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_failed_refresh() + assert self._vehicle_availability() == "online" + + def test_force_offline_is_immediate(self) -> None: + self.vehicle_state.mark_successful_refresh() + self.vehicle_state.mark_failed_refresh(force_offline=True) + assert self._vehicle_availability() == "offline" + + def test_charge_data_unavailable_only_after_repeated_failures(self) -> None: + self.vehicle_state.mark_charge_data_received() + assert self._charge_data_availability() == "online" + + self.vehicle_state.mark_charge_data_failed() + self.vehicle_state.mark_charge_data_failed() + assert self._charge_data_availability() == "online" + + self.vehicle_state.mark_charge_data_failed() + assert self._charge_data_availability() == "offline" + + def test_charge_data_recovers_and_resets_failure_count(self) -> None: + for _ in range(3): + self.vehicle_state.mark_charge_data_failed() + assert self._charge_data_availability() == "offline" + + self.vehicle_state.mark_charge_data_received() + assert self._charge_data_availability() == "online" + + self.vehicle_state.mark_charge_data_failed() + assert self._charge_data_availability() == "online" + async def test_update_soc_with_no_bms_data(self) -> None: vehicle_status_resp = get_mock_vehicle_status_resp() result = self.vehicle_state.handle_vehicle_status(vehicle_status_resp) @@ -557,6 +634,29 @@ def test_polling_phase_republished_after_transition_back(self) -> None: assert self.publisher.map[phase_topic] == PollingPhase.OFF.value assert self.publisher.publish_count[phase_topic] == 3 + async def test_scheduled_charging_job_runs_on_event_loop(self) -> None: + """Regression for #484: sync jobs run in a worker thread, not on the loop.""" + scheduler = AsyncIOScheduler() + vin_info = VinInfo() + vin_info.vin = VIN + state = VehicleState( + self.publisher, scheduler, f"/vehicles/{VIN}", VehicleInfo(vin_info, None) + ) + state.update_scheduled_charging( + datetime.time(hour=23, minute=10), + ScheduledChargingMode.UNTIL_CONFIGURED_TIME, + ) + job = scheduler.get_job(f"{VIN}_scheduled_charging") + assert job is not None + assert inspect.iscoroutinefunction(job.func) + + await job.func() + + assert state.refresh_mode == RefreshMode.FORCE + assert self.publisher.map[self.get_topic(mqtt_topics.REFRESH_MODE)] == ( + RefreshMode.FORCE.value + ) + @staticmethod def get_topic(sub_topic: str) -> str: return f"/vehicles/{VIN}/{sub_topic}"