diff --git a/.github/workflows/build_and_test_python.yml b/.github/workflows/ci.yml similarity index 54% rename from .github/workflows/build_and_test_python.yml rename to .github/workflows/ci.yml index 177b1bd..22ccc57 100644 --- a/.github/workflows/build_and_test_python.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,31 @@ -name: Python tests +name: CI +run-name: "${{ github.event_name == 'workflow_dispatch' && format('CI: {0}', github.ref_name) || '' }}" on: push: branches: - '*' pull_request: - types: [ opened, synchronize, reopened ] - branches: - - '*' + release: + types: [ published ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ (github.event_name == 'release' && github.run_id) || github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false matrix: - python-version: [ "3.11", "3.12" ] + python-version: [ "3.11", "3.12", "3.13" ] steps: - name: Checkout code @@ -44,17 +54,27 @@ jobs: run: | poetry install --no-interaction --no-root - - name: Install library + - name: Install as a library run: poetry install --no-interaction - - name: Run tests with coverage + - name: Run mypy run: | - poetry run pytest tests --cov --junit-xml=junit/test-results-${{ matrix.python-version }}.xml + poetry run mypy src - name: Lint with Ruff run: | - poetry run ruff check . - continue-on-error: true + poetry run ruff check . --output-format=github + if: success() || failure() + + - name: Lint with pylint + run: | + poetry run pylint src/**/*.py + if: success() || failure() + + - name: Run tests with coverage + run: | + poetry run pytest tests --cov --junit-xml=junit/test-results-${{ matrix.python-version }}.xml + if: success() || failure() - name: Surface failing tests uses: pmeier/pytest-results-action@main @@ -70,4 +90,32 @@ jobs: # (Optional) Fail the workflow if no JUnit XML was found. fail-on-empty: true - if: ${{ always() }} \ No newline at end of file + if: success() || failure() + + release: + name: Release to PyPi + if: ${{ github.event_name == 'release' }} + runs-on: ubuntu-latest + needs: + - build + + environment: + name: release + url: https://pypi.org/p/saic_ismart_client_ng + + permissions: + contents: read + + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Build and publish to pypi + uses: JRubics/poetry-publish@v2.0 + with: + pypi_token: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index e697993..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [ published ] - workflow_dispatch: - -permissions: - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Build and publish to pypi - uses: JRubics/poetry-publish@v2.0 - with: - pypi_token: ${{ secrets.PYPI_API_TOKEN }} diff --git a/examples/main.py b/examples/main.py index 96250de..26a0d5a 100644 --- a/examples/main.py +++ b/examples/main.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging import sys @@ -6,11 +8,12 @@ from saic_ismart_client_ng.model import SaicApiConfiguration -async def main(): - config = SaicApiConfiguration(username="XXXXX@xxxx.com", password="XXXXX", ) - saic_api = SaicApi( - config +async def main() -> None: + config = SaicApiConfiguration( + username="XXXXX@xxxx.com", + password="XXXXX", ) + saic_api = SaicApi(config) await saic_api.login() while True: logging.info("Auth token expires at %s", saic_api.token_expiration) @@ -26,7 +29,7 @@ async def main(): await asyncio.sleep(10) -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig( stream=sys.stdout, format="%(levelname)s [%(asctime)s] %(name)s - %(message)s", diff --git a/poetry.lock b/poetry.lock index 9dc74fd..fcba2ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,36 +1,49 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, - {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, ] [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "astroid" +version = "3.3.9" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "astroid-3.3.9-py3-none-any.whl", hash = "sha256:d05bfd0acba96a7bd43e222828b7d9bc1e138aaeb0649707908d3702a9831248"}, + {file = "astroid-3.3.9.tar.gz", hash = "sha256:622cc8e3048684aa42c820d9d218978021c3c3d174fb03a9f0d615921744f550"}, +] + [[package]] name = "certifi" -version = "2024.8.30" +version = "2025.4.26" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, + {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, ] [[package]] @@ -48,120 +61,138 @@ files = [ [[package]] name = "coverage" -version = "7.6.4" +version = "7.8.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "coverage-7.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07"}, - {file = "coverage-7.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51"}, - {file = "coverage-7.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea"}, - {file = "coverage-7.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a"}, - {file = "coverage-7.6.4-cp310-cp310-win32.whl", hash = "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa"}, - {file = "coverage-7.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172"}, - {file = "coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b"}, - {file = "coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b"}, - {file = "coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db"}, - {file = "coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522"}, - {file = "coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf"}, - {file = "coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19"}, - {file = "coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2"}, - {file = "coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27"}, - {file = "coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1"}, - {file = "coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5"}, - {file = "coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17"}, - {file = "coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08"}, - {file = "coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9"}, - {file = "coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06"}, - {file = "coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21"}, - {file = "coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a"}, - {file = "coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e"}, - {file = "coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963"}, - {file = "coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f"}, - {file = "coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3"}, - {file = "coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70"}, - {file = "coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef"}, - {file = "coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e"}, - {file = "coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1"}, - {file = "coverage-7.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3"}, - {file = "coverage-7.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376"}, - {file = "coverage-7.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111"}, - {file = "coverage-7.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901"}, - {file = "coverage-7.6.4-cp39-cp39-win32.whl", hash = "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09"}, - {file = "coverage-7.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f"}, - {file = "coverage-7.6.4-pp39.pp310-none-any.whl", hash = "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e"}, - {file = "coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73"}, + {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, + {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, + {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, + {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, + {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, + {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, + {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, + {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, + {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, + {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, + {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, + {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, + {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, + {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, + {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, + {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, + {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, + {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, + {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, + {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, + {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, + {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, + {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, + {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, + {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, + {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, + {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, + {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, + {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, + {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, + {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, + {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, + {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, + {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, + {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, + {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, + {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, + {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, + {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, + {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, + {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, + {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, + {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, + {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, + {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, + {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, + {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, + {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, + {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, + {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, + {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, + {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, + {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, + {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, + {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, + {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, + {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, + {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, + {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, + {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, + {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, + {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, + {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, ] [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "dacite" -version = "1.8.1" +version = "1.9.2" description = "Simple creation of data classes from dictionaries." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "dacite-1.8.1-py3-none-any.whl", hash = "sha256:cc31ad6fdea1f49962ea42db9421772afe01ac5442380d9a99fcf3d188c61afe"}, + {file = "dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0"}, + {file = "dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09"}, ] [package.extras] dev = ["black", "coveralls", "mypy", "pre-commit", "pylint", "pytest (>=5)", "pytest-benchmark", "pytest-cov"] +[[package]] +name = "dill" +version = "0.4.0" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, + {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "httpcore" -version = "1.0.6" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, - {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] @@ -171,14 +202,14 @@ trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [package.dependencies] @@ -186,10 +217,9 @@ anyio = "*" certifi = "*" httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -212,26 +242,54 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] [[package]] name = "mock" -version = "5.1.0" +version = "5.2.0" description = "Rolling backport of unittest.mock for all Pythons" optional = false python-versions = ">=3.6" groups = ["dev"] files = [ - {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, - {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, + {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, + {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, ] [package.extras] @@ -239,18 +297,100 @@ build = ["blurb", "twine", "wheel"] docs = ["sphinx"] test = ["pytest", "pytest-cov"] +[[package]] +name = "mypy" +version = "1.15.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + [[package]] name = "packaging" -version = "24.1" +version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "platformdirs" +version = "4.3.7" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, + {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + [[package]] name = "pluggy" version = "1.5.0" @@ -269,56 +409,81 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pycryptodome" -version = "3.21.0" +version = "3.22.0" description = "Cryptographic library for Python" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] files = [ - {file = "pycryptodome-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dad9bf36eda068e89059d1f07408e397856be9511d7113ea4b586642a429a4fd"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a1752eca64c60852f38bb29e2c86fca30d7672c024128ef5d70cc15868fa10f4"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3ba4cc304eac4d4d458f508d4955a88ba25026890e8abff9b60404f76a62c55e"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cb087b8612c8a1a14cf37dd754685be9a8d9869bed2ffaaceb04850a8aeef7e"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:26412b21df30b2861424a6c6d5b1d8ca8107612a4cfa4d0183e71c5d200fb34a"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:cc2269ab4bce40b027b49663d61d816903a4bd90ad88cb99ed561aadb3888dd3"}, - {file = "pycryptodome-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0fa0a05a6a697ccbf2a12cec3d6d2650b50881899b845fac6e87416f8cb7e87d"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6cce52e196a5f1d6797ff7946cdff2038d3b5f0aba4a43cb6bf46b575fd1b5bb"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:a915597ffccabe902e7090e199a7bf7a381c5506a747d5e9d27ba55197a2c568"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e74c522d630766b03a836c15bff77cb657c5fdf098abf8b1ada2aebc7d0819"}, - {file = "pycryptodome-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:a3804675283f4764a02db05f5191eb8fec2bb6ca34d466167fc78a5f05bbe6b3"}, - {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:2480ec2c72438430da9f601ebc12c518c093c13111a5c1644c82cdfc2e50b1e4"}, - {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:de18954104667f565e2fbb4783b56667f30fb49c4d79b346f52a29cb198d5b6b"}, - {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de4b7263a33947ff440412339cb72b28a5a4c769b5c1ca19e33dd6cd1dcec6e"}, - {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0714206d467fc911042d01ea3a1847c847bc10884cf674c82e12915cfe1649f8"}, - {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d85c1b613121ed3dbaa5a97369b3b757909531a959d229406a75b912dd51dd1"}, - {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8898a66425a57bcf15e25fc19c12490b87bd939800f39a03ea2de2aea5e3611a"}, - {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:932c905b71a56474bff8a9c014030bc3c882cee696b448af920399f730a650c2"}, - {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18caa8cfbc676eaaf28613637a89980ad2fd96e00c564135bf90bc3f0b34dd93"}, - {file = "pycryptodome-3.21.0-cp36-abi3-win32.whl", hash = "sha256:280b67d20e33bb63171d55b1067f61fbd932e0b1ad976b3a184303a3dad22764"}, - {file = "pycryptodome-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53"}, - {file = "pycryptodome-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:2cb635b67011bc147c257e61ce864879ffe6d03342dc74b6045059dfbdedafca"}, - {file = "pycryptodome-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:4c26a2f0dc15f81ea3afa3b0c87b87e501f235d332b7f27e2225ecb80c0b1cdd"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d5ebe0763c982f069d3877832254f64974139f4f9655058452603ff559c482e8"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee86cbde706be13f2dec5a42b52b1c1d1cbb90c8e405c68d0755134735c8dc6"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fd54003ec3ce4e0f16c484a10bc5d8b9bd77fa662a12b85779a2d2d85d67ee0"}, - {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5dfafca172933506773482b0e18f0cd766fd3920bd03ec85a283df90d8a17bc6"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:590ef0898a4b0a15485b05210b4a1c9de8806d3ad3d47f74ab1dc07c67a6827f"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35e442630bc4bc2e1878482d6f59ea22e280d7121d7adeaedba58c23ab6386b"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff99f952db3db2fbe98a0b355175f93ec334ba3d01bbde25ad3a5a33abc02b58"}, - {file = "pycryptodome-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8acd7d34af70ee63f9a849f957558e49a98f8f1634f86a59d2be62bb8e93f71c"}, - {file = "pycryptodome-3.21.0.tar.gz", hash = "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297"}, + {file = "pycryptodome-3.22.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:96e73527c9185a3d9b4c6d1cfb4494f6ced418573150be170f6580cb975a7f5a"}, + {file = "pycryptodome-3.22.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:9e1bb165ea1dc83a11e5dbbe00ef2c378d148f3a2d3834fb5ba4e0f6fd0afe4b"}, + {file = "pycryptodome-3.22.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:d4d1174677855c266eed5c4b4e25daa4225ad0c9ffe7584bb1816767892545d0"}, + {file = "pycryptodome-3.22.0-cp27-cp27m-win32.whl", hash = "sha256:9dbb749cef71c28271484cbef684f9b5b19962153487735411e1020ca3f59cb1"}, + {file = "pycryptodome-3.22.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f1ae7beb64d4fc4903a6a6cca80f1f448e7a8a95b77d106f8a29f2eb44d17547"}, + {file = "pycryptodome-3.22.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:a26bcfee1293b7257c83b0bd13235a4ee58165352be4f8c45db851ba46996dc6"}, + {file = "pycryptodome-3.22.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:009e1c80eea42401a5bd5983c4bab8d516aef22e014a4705622e24e6d9d703c6"}, + {file = "pycryptodome-3.22.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3b76fa80daeff9519d7e9f6d9e40708f2fce36b9295a847f00624a08293f4f00"}, + {file = "pycryptodome-3.22.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a31fa5914b255ab62aac9265654292ce0404f6b66540a065f538466474baedbc"}, + {file = "pycryptodome-3.22.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0092fd476701eeeb04df5cc509d8b739fa381583cda6a46ff0a60639b7cd70d"}, + {file = "pycryptodome-3.22.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d5b0ddc7cf69231736d778bd3ae2b3efb681ae33b64b0c92fb4626bb48bb89"}, + {file = "pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f6cf6aa36fcf463e622d2165a5ad9963b2762bebae2f632d719dfb8544903cf5"}, + {file = "pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:aec7b40a7ea5af7c40f8837adf20a137d5e11a6eb202cde7e588a48fb2d871a8"}, + {file = "pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d21c1eda2f42211f18a25db4eaf8056c94a8563cd39da3683f89fe0d881fb772"}, + {file = "pycryptodome-3.22.0-cp37-abi3-win32.whl", hash = "sha256:f02baa9f5e35934c6e8dcec91fcde96612bdefef6e442813b8ea34e82c84bbfb"}, + {file = "pycryptodome-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:d086aed307e96d40c23c42418cbbca22ecc0ab4a8a0e24f87932eeab26c08627"}, + {file = "pycryptodome-3.22.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:98fd9da809d5675f3a65dcd9ed384b9dc67edab6a4cda150c5870a8122ec961d"}, + {file = "pycryptodome-3.22.0-pp27-pypy_73-win32.whl", hash = "sha256:37ddcd18284e6b36b0a71ea495a4c4dca35bb09ccc9bfd5b91bfaf2321f131c1"}, + {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b4bdce34af16c1dcc7f8c66185684be15f5818afd2a82b75a4ce6b55f9783e13"}, + {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2988ffcd5137dc2d27eb51cd18c0f0f68e5b009d5fec56fbccb638f90934f333"}, + {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e653519dedcd1532788547f00eeb6108cc7ce9efdf5cc9996abce0d53f95d5a9"}, + {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5810bc7494e4ac12a4afef5a32218129e7d3890ce3f2b5ec520cc69eb1102ad"}, + {file = "pycryptodome-3.22.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7514a1aebee8e85802d154fdb261381f1cb9b7c5a54594545145b8ec3056ae6"}, + {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:56c6f9342fcb6c74e205fbd2fee568ec4cdbdaa6165c8fde55dbc4ba5f584464"}, + {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87a88dc543b62b5c669895caf6c5a958ac7abc8863919e94b7a6cafd2f64064f"}, + {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a683bc9fa585c0dfec7fa4801c96a48d30b30b096e3297f9374f40c2fedafc"}, + {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f4f6f47a7f411f2c157e77bbbda289e0c9f9e1e9944caa73c1c2e33f3f92d6e"}, + {file = "pycryptodome-3.22.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6cf9553b29624961cab0785a3177a333e09e37ba62ad22314ebdbb01ca79840"}, + {file = "pycryptodome-3.22.0.tar.gz", hash = "sha256:fd7ab568b3ad7b77c908d7c3f7e167ec5a8f035c64ff74f10d47a4edd043d723"}, ] +[[package]] +name = "pylint" +version = "3.3.6" +description = "python code static checker" +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "pylint-3.3.6-py3-none-any.whl", hash = "sha256:8b7c2d3e86ae3f94fb27703d521dd0b9b6b378775991f504d7c3a6275aa0a6a6"}, + {file = "pylint-3.3.6.tar.gz", hash = "sha256:b634a041aac33706d56a0d217e6587228c66427e20ec21a019bc4cdee48c040a"}, +] + +[package.dependencies] +astroid = ">=3.3.8,<=3.4.0.dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = [ + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, + {version = ">=0.3.6", markers = "python_version == \"3.11\""}, +] +isort = ">=4.2.5,<5.13 || >5.13,<7" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2" +tomlkit = ">=0.10.1" + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + [[package]] name = "pytest" -version = "8.3.3" +version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, ] [package.dependencies] @@ -388,30 +553,30 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "ruff" -version = "0.6.9" +version = "0.11.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, - {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, - {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, - {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, - {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, - {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, - {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, + {file = "ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c"}, + {file = "ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee"}, + {file = "ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63"}, + {file = "ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6"}, + {file = "ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6"}, + {file = "ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26"}, + {file = "ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a"}, + {file = "ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177"}, + {file = "ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4"}, ] [[package]] @@ -428,21 +593,46 @@ files = [ [[package]] name = "tenacity" -version = "9.0.0" +version = "9.1.2" description = "Retry code until it succeeds" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, - {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, ] [package.extras] doc = ["reno", "sphinx"] test = ["pytest", "tornado (>=4.5)", "typeguard"] +[[package]] +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] +markers = {main = "python_version < \"3.13\""} + [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "60aafce94b4e78bd794881121f3ead676f4fb145394e29e62bf6d76ad57cbbcb" +content-hash = "c4d5825844d9cc966c0f5e409ae372b02442f57103e3921f279297f6ec9b20ba" diff --git a/pyproject.toml b/pyproject.toml index 89a7666..bd02195 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,9 @@ -[tool.poetry] +[project] name = "saic_ismart_client_ng" -homepage = "https://github.com/SAIC-iSmart-API/saic-python-client-ng" -version = "0.7.1" +version = "0.8.0" description = "SAIC next gen client library (MG iSMART)" authors = [ - "Giovanni Condello ", + { name = "Giovanni Condello", email = "saic-python-client@nanomad.net" }, ] readme = "README.md" @@ -14,26 +13,29 @@ classifiers = [ "Operating System :: OS Independent", ] -[tool.poetry.urls] -"Bug Tracker" = "https://github.com/SAIC-iSmart-API/saic-python-client-ng/issues" +requires-python = ">=3.11" +dependencies = [ + "pycryptodome (>=3.20.0,<4.0.0)", + "httpx (>=0.27.0,<0.29.0)", + "tenacity (>=9.0.0,<10.0.0)", + "dacite (>=1.8.1,<2.0.0)" +] -[tool.poetry.dependencies] -python = "^3.11" -pycryptodome = "^3.20.0" -httpx = ">=0.27.0,<0.29.0" -tenacity = "^9.0.0" -dacite = "^1.8.1" - +[project.urls] +Homepage = "https://github.com/SAIC-iSmart-API/saic-python-client-ng" +Issues = "https://github.com/SAIC-iSmart-API/saic-python-client-ng/issues" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "^8.2.2" mock = "^5.1.0" coverage = "^7.5.4" -ruff = "^0.6.0" +ruff = "^0.11.7" pytest-cov = "^5.0.0" pytest-asyncio = "^0.24.0" pytest-mock = "^3.14.0" +mypy = "^1.15.0" +pylint = "^3.3.6" @@ -51,7 +53,7 @@ mock_use_standalone_module = true addopts = [ "--import-mode=importlib", ] -asyncio_default_fixture_loop_scope="function" +asyncio_default_fixture_loop_scope = "function" [tool.coverage.run] omit = [ @@ -78,4 +80,151 @@ exclude_lines = [ ignore_errors = true [tool.ruff] -output-format = "github" +include = [ + "src/**/*.py", + "tests/**/*.py", + "**/pyproject.toml" +] +[tool.ruff.lint] +select = ["ALL"] + +ignore = [ + "ANN401", # Opinioated warning on disallowing dynamically typed expressions + "D203", # Conflicts with other rules + "D213", # Conflicts with other rules + "EM101", # raw-string-in-exception + + "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` + "E501", # line too long + + "FBT", # flake8-boolean-trap + + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + + # Used to map JSON responses + "N815", + # Conflicts with the Ruff formatter + "COM812", + # We use Exception istead of Error + "N818", + # Remove later + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D106", # Missing docstring in public nested class + "TD", # Todos + "A", # bultins + "DTZ", # use tz need to test it first + "TRY", # tryceratops + "FIX002", # Line contains TODO, consider resolving the issue + "BLE001", # Do not catch blind exception: `Exception`, + "PLR0913", # Too many arguments in function definition + "ERA001" # Commented-out code + +] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + + +[tool.ruff.lint.isort] +combine-as-imports = true +force-sort-within-sections = true +known-first-party = ["saic_ismart_client_ng"] +required-imports = ["from __future__ import annotations"] + + +[tool.ruff.lint.per-file-ignores] +"tests/**" = [ + "D100", # Missing docstring in public module + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "N802", # Function name {name} should be lowercase + "N816", # Variable {name} in global scope should not be mixedCase + "PLR0913", # Too many arguments in function definition + "S101", # Use of assert detected + "SLF001", # Private member accessed: {access} + "T201", # print found +] + +[tool.ruff.lint.mccabe] +max-complexity = 13 + +[tool.ruff.lint.pylint] +max-args = 7 + +[tool.mypy] +files = ["./src", "./tests"] +python_version = 3.11 +show_error_codes = true +strict_equality = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true +strict = true + +[tool.pylint.MAIN] +py-version = "3.11" +ignore = ["tests"] +fail-on = ["I"] + +[tool.pylint.BASIC] +good-names = ["i", "j", "k", "ex", "_", "T", "x", "y", "id", "tg"] + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by black +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +# --- +# Pylint CodeStyle plugin +# consider-using-namedtuple-or-dataclass - too opinionated +# consider-using-assignment-expr - decision to use := better left to devs +disable = [ + "format", + "cyclic-import", + "duplicate-code", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-locals", + "too-many-ancestors", + "too-few-public-methods", + "invalid-name", + # Remove later + "missing-function-docstring", + "missing-module-docstring", + "missing-class-docstring", + "broad-exception-caught", + "logging-fstring-interpolation", + "fixme" +] +enable = ["useless-suppression", "use-symbolic-message-instead"] + +[tool.pylint.REPORTS] +score = false + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF" + +[tool.pylint.EXCEPTIONS] +overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"] \ No newline at end of file diff --git a/src/saic_ismart_client_ng/__init__.py b/src/saic_ismart_client_ng/__init__.py index 7711485..9983ba2 100644 --- a/src/saic_ismart_client_ng/__init__.py +++ b/src/saic_ismart_client_ng/__init__.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from saic_ismart_client_ng.api.message import SaicMessageApi from saic_ismart_client_ng.api.user import SaicUserApi -from saic_ismart_client_ng.api.vehicle import SaicVehicleApi as SaicVehicleApi from saic_ismart_client_ng.api.vehicle.alarm import SaicVehicleAlarmApi from saic_ismart_client_ng.api.vehicle.climate import SaicVehicleClimateApi from saic_ismart_client_ng.api.vehicle.locks import SaicVehicleLocksApi @@ -15,6 +16,6 @@ class SaicApi( SaicVehicleLocksApi, SaicVehicleWindowsApi, SaicVehicleClimateApi, - SaicVehicleChargingApi + SaicVehicleChargingApi, ): - """ The SAIC Api client """ + """The SAIC Api client.""" diff --git a/src/saic_ismart_client_ng/api/base.py b/src/saic_ismart_client_ng/api/base.py index 0f9a152..4429824 100644 --- a/src/saic_ismart_client_ng/api/base.py +++ b/src/saic_ismart_client_ng/api/base.py @@ -1,40 +1,63 @@ +from __future__ import annotations + +from dataclasses import asdict import datetime -import json import logging -from abc import ABC -from dataclasses import asdict -from typing import Type, T, Optional, Any +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Protocol, + TypeVar, +) import dacite import httpx import tenacity -from httpx._types import QueryParamTypes, HeaderTypes +from tenacity import RetryCallState, retry_if_exception from saic_ismart_client_ng.api.schema import LoginResp from saic_ismart_client_ng.crypto_utils import sha1_hex_digest -from saic_ismart_client_ng.exceptions import SaicApiException, SaicApiRetryException, SaicLogoutException -from saic_ismart_client_ng.listener import SaicApiListener -from saic_ismart_client_ng.model import SaicApiConfiguration +from saic_ismart_client_ng.exceptions import ( + SaicApiException, + SaicApiRetryException, + SaicLogoutException, +) from saic_ismart_client_ng.net.client import SaicApiClient +if TYPE_CHECKING: + from collections.abc import MutableMapping + + from httpx._types import HeaderTypes, QueryParamTypes + + from saic_ismart_client_ng.listener import SaicApiListener + from saic_ismart_client_ng.model import SaicApiConfiguration + + class IsDataclass(Protocol): + # as already noted in comments, checking for this attribute is currently + # the most reliable way to ascertain that something is a dataclass + __dataclass_fields__: ClassVar[dict[str, Any]] + + T = TypeVar("T", bound=IsDataclass) + logger = logging.getLogger(__name__) -class AbstractSaicApi(ABC): +class AbstractSaicApi: def __init__( - self, - configuration: SaicApiConfiguration, - listener: SaicApiListener = None, - ): + self, + configuration: SaicApiConfiguration, + listener: SaicApiListener | None = None, + ) -> None: self.__configuration = configuration self.__api_client = SaicApiClient(configuration, listener=listener) - self.__token_expiration: Optional[datetime.datetime] = None + self.__token_expiration: datetime.datetime | None = None async def login(self) -> LoginResp: headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", - "Authorization": "Basic c3dvcmQ6c3dvcmRfc2VjcmV0" + "Authorization": "Basic c3dvcmQ6c3dvcmRfc2VjcmV0", } firebase_device_id = "cqSHOMG1SmK4k-fzAeK6hr:APA91bGtGihOG5SEQ9hPx3Dtr9o9mQguNiKZrQzboa-1C_UBlRZYdFcMmdfLvh9Q_xA8A0dGFIjkMhZbdIXOYnKfHCeWafAfLXOrxBS3N18T4Slr-x9qpV6FHLMhE9s7I6s89k9lU7DD" form_body = { @@ -45,7 +68,9 @@ async def login(self) -> LoginResp: "deviceId": f"{firebase_device_id}###europecar", "deviceType": "1", # 2 for huawei "loginType": "2" if self.__configuration.username_is_email else "1", - "countryCode": "" if self.__configuration.username_is_email else self.__configuration.phone_country_code, + "countryCode": "" + if self.__configuration.username_is_email + else self.__configuration.phone_country_code, } result = await self.execute_api_call( @@ -53,210 +78,315 @@ async def login(self) -> LoginResp: "/oauth/token", form_body=form_body, out_type=LoginResp, - headers=headers + headers=headers, ) # Update the user token - self.__api_client.user_token = result.access_token - self.__token_expiration = datetime.datetime.now() + datetime.timedelta(seconds=result.expires_in) + if not (access_token := result.access_token) or not ( + expiration := result.expires_in + ): + raise SaicApiException( + "Failed to get an access token, please check your credentials" + ) + + self.__api_client.user_token = access_token + self.__token_expiration = datetime.datetime.now() + datetime.timedelta( + seconds=expiration + ) return result async def execute_api_call( - self, - method: str, - path: str, - *, - body: Optional[Any] = None, - form_body: Optional[Any] = None, - out_type: Optional[Type[T]] = None, - params: Optional[QueryParamTypes] = None, - headers: Optional[HeaderTypes] = None, - allow_null_body: bool = False, - ) -> Optional[T]: + self, + method: str, + path: str, + *, + body: Any | None = None, + form_body: Any | None = None, + out_type: type[T], + params: QueryParamTypes | None = None, + headers: HeaderTypes | None = None, + allow_null_body: bool = False, + ) -> T: + result = await self.__execute_api_call( + method, + path, + body=body, + form_body=form_body, + out_type=out_type, + params=params, + headers=headers, + allow_null_body=allow_null_body, + ) + if result is None: + msg = f"Failed to execute api call {method} {path}, was expecting a result of type {out_type} got None instead" + raise SaicApiException(msg) + return result + + async def execute_api_call_no_result( + self, + method: str, + path: str, + *, + body: Any | None = None, + form_body: Any | None = None, + params: QueryParamTypes | None = None, + headers: HeaderTypes | None = None, + allow_null_body: bool = False, + ) -> None: + await self.__execute_api_call( + method, + path, + body=body, + form_body=form_body, + params=params, + headers=headers, + allow_null_body=allow_null_body, + ) + + async def __execute_api_call( + self, + method: str, + path: str, + *, + body: Any | None = None, + form_body: Any | None = None, + out_type: type[T] | None = None, + params: QueryParamTypes | None = None, + headers: HeaderTypes | None = None, + allow_null_body: bool = False, + ) -> T | None: try: - return await self.__execute_api_call( + url = f"{self.__configuration.base_uri}{path.removeprefix('/')}" + json_body = asdict(body) if body else None + req = httpx.Request( method, - path, - body=body, - form_body=form_body, - out_type=out_type, + url, params=params, headers=headers, - allow_null_body=allow_null_body + data=form_body, + json=json_body, ) + response = await self.__api_client.send(req) + return await self.__deserialize(req, response, out_type, allow_null_body) except SaicApiException as e: raise e except Exception as e: - raise SaicApiException(f"API call {method} {path} failed unexpectedly", return_code=500) from e - - async def __execute_api_call( - self, - method: str, - path: str, - *, - body: Optional[Any] = None, - form_body: Optional[Any] = None, - out_type: Optional[Type[T]] = None, - params: Optional[QueryParamTypes] = None, - headers: Optional[HeaderTypes] = None, - allow_null_body: bool = False, - ) -> Optional[T]: - url = f"{self.__configuration.base_uri}{path[1:] if path.startswith('/') else path}" - json_body = asdict(body) if body else None - req = httpx.Request(method, url, params=params, headers=headers, data=form_body, json=json_body) - response = await self.__api_client.send(req) - return await self.__deserialize(req, response, out_type, allow_null_body) + msg = f"API call {method} {path} failed unexpectedly" + raise SaicApiException(msg, return_code=500) from e async def execute_api_call_with_event_id( - self, - method: str, - path: str, - *, - body: Optional[Any] = None, - out_type: Optional[Type[T]] = None, - params: Optional[QueryParamTypes] = None, - headers: Optional[HeaderTypes] = None, - delay: Optional[tenacity.wait.WaitBaseT] = None, - ) -> Optional[T]: + self, + method: str, + path: str, + *, + body: Any | None = None, + out_type: type[T], + params: QueryParamTypes | None = None, + headers: MutableMapping[str, str] | None = None, + delay: tenacity.wait.WaitBaseT | None = None, + ) -> T: + result = await self.__execute_api_call_with_event_id( + method, + path, + body=body, + out_type=out_type, + params=params, + headers=headers, + delay=delay, + ) + if result is None: + msg = f"Failed to execute api call {method} {path}, was expecting a result of type {out_type} got None instead" + raise SaicApiException(msg) + return result + + async def execute_api_call_with_event_id_no_result( + self, + method: str, + path: str, + *, + body: Any | None = None, + params: QueryParamTypes | None = None, + headers: MutableMapping[str, str] | None = None, + delay: tenacity.wait.WaitBaseT | None = None, + ) -> None: + await self.__execute_api_call_with_event_id( + method, + path, + body=body, + params=params, + headers=headers, + delay=delay, + ) + + async def __execute_api_call_with_event_id( + self, + method: str, + path: str, + *, + body: Any | None = None, + out_type: type[T] | None = None, + params: QueryParamTypes | None = None, + headers: MutableMapping[str, str] | None = None, + delay: tenacity.wait.WaitBaseT | None = None, + ) -> T | None: @tenacity.retry( stop=tenacity.stop_after_delay(30), wait=delay or tenacity.wait_fixed(self.__configuration.sms_delivery_delay), - retry=saic_api_retry_policy, + retry=SaicApiRetryPolicy(), after=saic_api_after_retry, reraise=True, ) - async def execute_api_call_with_event_id_inner(*, event_id: str): - actual_headers = headers or dict() - actual_headers.update({'event-id': event_id}) + async def execute_api_call_with_event_id_inner(*, event_id: str) -> T | None: + actual_headers = headers or {} + actual_headers.update({"event-id": event_id}) return await self.__execute_api_call( method, path, body=body, out_type=out_type, params=params, - headers=actual_headers + headers=actual_headers, ) - return await execute_api_call_with_event_id_inner(event_id='0') + return await execute_api_call_with_event_id_inner(event_id="0") + # pylint: disable=too-many-branches async def __deserialize( - self, - request: httpx.Request, - response: httpx.Response, - data_class: Optional[Type[T]], - allow_null_body: bool - ) -> Optional[T]: - + self, + request: httpx.Request, + response: httpx.Response, + data_class: type[T] | None, + allow_null_body: bool, + ) -> T | None: try: - request_event_id = request.headers.get('event-id') + request_event_id = request.headers.get("event-id") json_data = response.json() - return_code = json_data.get('code', -1) - error_message = json_data.get('message', 'Unknown error') - logger.debug(f"Response code: {return_code} {response.text}") + return_code = json_data.get("code", -1) + error_message = json_data.get("message", "Unknown error") + logger.debug("Response code: %s %s", return_code, response.text) if return_code in (401, 403) or response.status_code in (401, 403): self.logout() raise SaicLogoutException(response.text, return_code) if return_code in (2, 3, 7): - logger.error(f"API call return code is not acceptable: {return_code}: {response.text}") + logger.error( + "API call return code is not acceptable: %s: %s", + return_code, + response.text, + ) raise SaicApiException(error_message, return_code=return_code) - if 'event-id' in response.headers and 'data' not in json_data: - event_id = response.headers['event-id'] - logger.info(f"Retrying since we got event-id in headers: {event_id}, but no data") - raise SaicApiRetryException(error_message, event_id=event_id, return_code=return_code) + if "event-id" in response.headers and "data" not in json_data: + event_id = response.headers["event-id"] + logger.info( + "Retrying since we got event-id in headers: %s, but no data", + event_id, + ) + raise SaicApiRetryException( + error_message, event_id=event_id, return_code=return_code + ) if return_code != 0: - if request_event_id is not None and request_event_id != '0': + if request_event_id is not None and request_event_id != "0": logger.info( - f"API call asked us to retry: {return_code}: {response.text}. Event id was: {request_event_id}" + "API call asked us to retry: %s %s. Event id was: %s", + return_code, + response.text, + request_event_id, ) raise SaicApiRetryException( error_message, event_id=request_event_id, - return_code=return_code - ) - else: - logger.error( - f"API call return code is not acceptable: {return_code}: {response.text}. Headers: {response.headers}" + return_code=return_code, ) - raise SaicApiException(error_message, return_code=return_code) + logger.error( + "API call return code is not acceptable: %s %s. Headers: %s", + return_code, + response.text, + response.headers, + ) + raise SaicApiException(error_message, return_code=return_code) if data_class is None: return None - elif 'data' in json_data: - if data_class is str: - return json.dumps(json_data['data']) - elif data_class is dict: - return json_data['data'] - else: - return dacite.from_dict(data_class, json_data['data']) - elif allow_null_body: + if "data" in json_data: + return dacite.from_dict(data_class, json_data["data"]) + if allow_null_body: return None - else: - raise SaicApiException(f"Failed to deserialize response, missing 'data' field: {response.text}") + msg = ( + f"Failed to deserialize response, missing 'data' field: {response.text}" + ) + raise SaicApiException(msg) except SaicApiException as se: raise se except Exception as e: if response.is_error: if response.status_code in (401, 403): - logger.error( - f"API call failed due to an authentication failure: {response.status_code} {response.text}", - exc_info=e + logger.exception( + "API call failed due to an authentication failure: %s %s", + response.status_code, + response.text, ) self.logout() - raise SaicLogoutException(response.text, response.status_code) from e - else: - logger.error( - f"API call failed: {response.status_code} {response.text}", - exc_info=e - ) - raise SaicApiException(response.text, response.status_code) from e - else: - raise SaicApiException(f"Failed to deserialize response: {e}. Original json was {response.text}") from e + raise SaicLogoutException( + response.text, response.status_code + ) from e + logger.exception( + "API call failed: %s %s", + response.status_code, + response.text, + ) + raise SaicApiException(response.text, response.status_code) from e + msg = f"Failed to deserialize response: {e}. Original json was {response.text}" + raise SaicApiException(msg) from e - def logout(self): - self.__api_client.user_token = '' + def logout(self) -> None: + self.__api_client.user_token = "" self.__token_expiration = None @property def is_logged_in(self) -> bool: return ( - self.__api_client.user_token is not None and - self.__token_expiration is not None and - self.__token_expiration > datetime.datetime.now() + self.__api_client.user_token is not None + and self.__token_expiration is not None + and self.__token_expiration > datetime.datetime.now() ) @property - def token_expiration(self) -> Optional[datetime.datetime]: + def token_expiration(self) -> datetime.datetime | None: return self.__token_expiration -def saic_api_after_retry(retry_state): +def saic_api_after_retry(retry_state: RetryCallState) -> None: + if not retry_state.outcome: + return wrapped_exception = retry_state.outcome.exception() if isinstance(wrapped_exception, SaicApiRetryException): - if 'event_id' in retry_state.kwargs: - logger.debug(f"Updating event_id to the newly obtained value {wrapped_exception.event_id}") - retry_state.kwargs['event_id'] = wrapped_exception.event_id + if "event_id" in retry_state.kwargs: + event_id = wrapped_exception.event_id + logger.debug( + "Updating event_id to the newly obtained value %d", + event_id, + ) + retry_state.kwargs["event_id"] = event_id else: logger.debug("Retrying without an event_id") -def saic_api_retry_policy(retry_state): - is_failed = retry_state.outcome.failed - if is_failed: - wrapped_exception = retry_state.outcome.exception() - if isinstance(wrapped_exception, SaicApiRetryException): - logger.debug("Retrying since we got SaicApiRetryException") - return True - elif isinstance(wrapped_exception, SaicLogoutException): - logger.error("Not retrying since we got logged out") - return False - elif isinstance(wrapped_exception, SaicApiException): - logger.error("Not retrying since we got a generic exception") +class SaicApiRetryPolicy(retry_if_exception): + def __init__(self) -> None: + def __retry_policy(wrapped_exception: BaseException) -> bool: + if isinstance(wrapped_exception, SaicApiRetryException): + logger.debug("Retrying since we got SaicApiRetryException") + return True + if isinstance(wrapped_exception, SaicLogoutException): + logger.error("Not retrying since we got logged out") + return False + if isinstance(wrapped_exception, SaicApiException): + logger.error("Not retrying since we got a generic exception") + return False + logger.error("Not retrying", exc_info=wrapped_exception) return False - else: - logger.error(f"Not retrying {retry_state.args} {wrapped_exception}") - return False - return False + + super().__init__(__retry_policy) diff --git a/src/saic_ismart_client_ng/api/message/__init__.py b/src/saic_ismart_client_ng/api/message/__init__.py index 2df2c25..fa2fcef 100644 --- a/src/saic_ismart_client_ng/api/message/__init__.py +++ b/src/saic_ismart_client_ng/api/message/__init__.py @@ -1,20 +1,44 @@ -from typing import Optional, Union +from __future__ import annotations from saic_ismart_client_ng.api.base import AbstractSaicApi -from saic_ismart_client_ng.api.message.schema import MessageResp, UpateMessageRequest +from saic_ismart_client_ng.api.message.schema import ( + MessageEntity, + MessageResp, + UpateMessageRequest, +) + +__all__ = [ + "MessageEntity", + "MessageResp", + "UpateMessageRequest", +] class SaicMessageApi(AbstractSaicApi): - async def get_alarm_list(self, *, page_num: int, page_size: int) -> Optional[MessageResp]: - return await self.get_message_list(page_num=page_num, page_size=page_size, message_group='ALARM') + async def get_alarm_list( + self, *, page_num: int, page_size: int + ) -> MessageResp | None: + return await self.get_message_list( + page_num=page_num, page_size=page_size, message_group="ALARM" + ) - async def get_command_list(self, *, page_num: int, page_size: int) -> Optional[MessageResp]: - return await self.get_message_list(page_num=page_num, page_size=page_size, message_group='COMMAND') + async def get_command_list( + self, *, page_num: int, page_size: int + ) -> MessageResp | None: + return await self.get_message_list( + page_num=page_num, page_size=page_size, message_group="COMMAND" + ) - async def get_news_list(self, *, page_num: int, page_size: int) -> Optional[MessageResp]: - return await self.get_message_list(page_num=page_num, page_size=page_size, message_group='NEWS') + async def get_news_list( + self, *, page_num: int, page_size: int + ) -> MessageResp | None: + return await self.get_message_list( + page_num=page_num, page_size=page_size, message_group="NEWS" + ) - async def get_message_list(self, *, page_num: int, page_size: int, message_group: str) -> Optional[MessageResp]: + async def get_message_list( + self, *, page_num: int, page_size: int, message_group: str + ) -> MessageResp | None: return await self.execute_api_call( "GET", "/message/list", @@ -24,42 +48,41 @@ async def get_message_list(self, *, page_num: int, page_size: int, message_group "messageGroup": message_group, }, out_type=MessageResp, - allow_null_body=True + allow_null_body=True, ) - async def delete_all_alarms(self): - return await self.__change_message_status(action='DELETE_ALARM') + async def delete_all_alarms(self) -> None: + await self.__change_message_status(action="DELETE_ALARM") - async def delete_all_commands(self): - return await self.__change_message_status(action='DELETE_COMMAND') + async def delete_all_commands(self) -> None: + await self.__change_message_status(action="DELETE_COMMAND") - async def delete_all_news(self): - return await self.__change_message_status(action='DELETE_NEWS') + async def delete_all_news(self) -> None: + await self.__change_message_status(action="DELETE_NEWS") - async def read_message(self, *, message_id: Union[str, int]): - return await self.__change_message_status(message_id=message_id, action='READ') + async def read_message(self, *, message_id: str | int) -> None: + await self.__change_message_status(message_id=message_id, action="READ") - async def delete_message(self, *, message_id: Union[str, int]): - return await self.__change_message_status(message_id=message_id, action='DELETE') + async def delete_message(self, *, message_id: str | int) -> None: + await self.__change_message_status(message_id=message_id, action="DELETE") - async def __change_message_status(self, *, action: str, message_id: Optional[Union[str, int]] = None): + async def __change_message_status( + self, *, action: str, message_id: str | int | None = None + ) -> None: request = UpateMessageRequest( actionType=action, messageId=message_id, ) - return await self.update_message_status(request) + await self.update_message_status(request) - async def update_message_status(self, data: UpateMessageRequest): - return await self.execute_api_call( + async def update_message_status(self, data: UpateMessageRequest) -> None: + await self.execute_api_call_no_result( "PUT", "/message/status", body=data, ) - async def get_unread_messages_count(self) -> Optional[MessageResp]: + async def get_unread_messages_count(self) -> MessageResp | None: return await self.execute_api_call( - "GET", - "/message/unreadCount", - out_type=MessageResp, - allow_null_body=True + "GET", "/message/unreadCount", out_type=MessageResp, allow_null_body=True ) diff --git a/src/saic_ismart_client_ng/api/message/schema.py b/src/saic_ismart_client_ng/api/message/schema.py index 6da9834..8cb8f5f 100644 --- a/src/saic_ismart_client_ng/api/message/schema.py +++ b/src/saic_ismart_client_ng/api/message/schema.py @@ -1,77 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass, field import datetime import logging -from dataclasses import dataclass, field -from typing import List, Optional, Union +from typing import Any LOGGER = logging.getLogger(__name__) -# FIXME: The API returns date-times inconsistently. This is terrible a workaround. +# The API returns date-times inconsistently. This is terrible a workaround. MESSAGE_DATE_TIME_FORMATS = [ - '%Y-%m-%d %H:%M:%S', - '%d-%m-%Y %H:%M:%S', - '%d/%m/%Y %H:%M:%S', + "%Y-%m-%d %H:%M:%S", + "%d-%m-%Y %H:%M:%S", + "%d/%m/%Y %H:%M:%S", ] @dataclass class MessageEntity: - content: str = None - contentId: str = None - contentIdList: list = field(default_factory=list) - createTime: int = None - messageId: Union[str, int] = None - messageTime: str = None - messageType: str = None - readStatus: int = None - sender: str = None - showCheckButton: bool = None - title: str = None - vin: str = None + content: str | None = None + contentId: str | None = None + contentIdList: list[Any] = field(default_factory=list) + createTime: int | None = None + messageId: str | int | None = None + messageTime: str | None = None + messageType: str | None = None + readStatus: int | None = None + sender: str | None = None + showCheckButton: bool | None = None + title: str | None = None + vin: str | None = None @property def message_time(self) -> datetime.datetime: if self.messageTime: for date_format in MESSAGE_DATE_TIME_FORMATS: try: - parsed_date = datetime.datetime.strptime(self.messageTime, date_format) - return parsed_date + return datetime.datetime.strptime(self.messageTime, date_format) except ValueError: pass - LOGGER.error('Could not parse messageTime \'%s\'. This is a bug. Please file a ticket', self.messageTime) + LOGGER.error( + "Could not parse messageTime '%s'. This is a bug. Please file a ticket", + self.messageTime, + ) return datetime.datetime.now() @property def read_status(self) -> str: if self.readStatus is None: - return 'unknown' - elif self.readStatus == 0: - return 'unread' - else: - return 'read' + return "unknown" + if self.readStatus == 0: + return "unread" + return "read" @property def details(self) -> str: - return f'ID: {self.messageId}, Time: {self.message_time}, Type: {self.messageType}, Title: {self.title}, ' \ - + f'Content: {self.content}, Status: {self.read_status}, Sender: {self.sender}, VIN: {self.vin}' + return ( + f"ID: {self.messageId}, Time: {self.message_time}, Type: {self.messageType}, Title: {self.title}, " + f"Content: {self.content}, Status: {self.read_status}, Sender: {self.sender}, VIN: {self.vin}" + ) @dataclass class MessageResp: - alarmNumber: int = None - commandNumber: int = None - messages: List[MessageEntity] = field(default_factory=list) - newsNumber: int = None + alarmNumber: int | None = None + commandNumber: int | None = None + messages: list[MessageEntity] = field(default_factory=list) + newsNumber: int | None = None # notifications: List[Any] = None - recordsNumber: int = None - totalNumber: int = None + recordsNumber: int | None = None + totalNumber: int | None = None @dataclass class UpateMessageRequest: - actionType: Optional[str] = None - deviceId: Optional[str] = None - messageGroup: Optional[str] = None - messageId: Optional[Union[str, int]] = None - notificationCount: Optional[int] = None - pageNum: Optional[int] = None - pageSize: Optional[int] = None + actionType: str | None = None + deviceId: str | None = None + messageGroup: str | None = None + messageId: str | int | None = None + notificationCount: int | None = None + pageNum: int | None = None + pageSize: int | None = None diff --git a/src/saic_ismart_client_ng/api/schema.py b/src/saic_ismart_client_ng/api/schema.py index 20ca493..91e8825 100644 --- a/src/saic_ismart_client_ng/api/schema.py +++ b/src/saic_ismart_client_ng/api/schema.py @@ -1,36 +1,38 @@ -import logging +from __future__ import annotations + from dataclasses import dataclass from enum import Enum +import logging logger = logging.getLogger(__name__) @dataclass -class LoginResp(): +class LoginResp: @dataclass - class LoginRespDetail(): - languageType: str = None, + class LoginRespDetail: + languageType: str | None = None - access_token: str = None, - account: str = None, - avatar: str = None - client_id: str = None, - dept_id: str = None, - detail: LoginRespDetail = None, - expires_in: int = None, - jti: str = None, - languageType: str = None, - license: str = None, - oauth_id: str = None, - post_id: str = None, - refresh_token: str = None, - role_id: str = None, - role_name: str = None, - scope: str = None, - tenant_id: str = None, - token_type: str = None, - user_id: str = None, - user_name: str = None, + access_token: str | None = None + account: str | None = None + avatar: str | None = None + client_id: str | None = None + dept_id: str | None = None + detail: LoginRespDetail | None = None + expires_in: int | None = None + jti: str | None = None + languageType: str | None = None + license: str | None = None + oauth_id: str | None = None + post_id: str | None = None + refresh_token: str | None = None + role_id: str | None = None + role_name: str | None = None + scope: str | None = None + tenant_id: str | None = None + token_type: str | None = None + user_id: str | None = None + user_name: str | None = None class GpsStatus(Enum): @@ -46,19 +48,19 @@ class GpsPosition: class WayPoint: @dataclass class Position: - altitude: int = None - latitude: int = None - longitude: int = None + altitude: int | None = None + latitude: int | None = None + longitude: int | None = None - hdop: int = None - heading: int = None - position: Position = None - satellites: int = None - speed: int = None + hdop: int | None = None + heading: int | None = None + position: Position | None = None + satellites: int | None = None + speed: int | None = None - gpsStatus: int = None - timeStamp: int = None - wayPoint: WayPoint = None + gpsStatus: int | None = None + timeStamp: int | None = None + wayPoint: WayPoint | None = None @property def gps_status_decoded(self) -> GpsStatus | None: @@ -68,5 +70,5 @@ def gps_status_decoded(self) -> GpsStatus | None: try: return GpsStatus(value) except ValueError: - logger.error(f"Could not decode {value} as GpsStatus") + logger.error("Could not decode %s as GpsStatus", value) return None diff --git a/src/saic_ismart_client_ng/api/serialization_utils.py b/src/saic_ismart_client_ng/api/serialization_utils.py index 23a88e4..71bc4e0 100644 --- a/src/saic_ismart_client_ng/api/serialization_utils.py +++ b/src/saic_ismart_client_ng/api/serialization_utils.py @@ -1,16 +1,17 @@ +from __future__ import annotations + import base64 import logging -from typing import Optional __LOGGER = logging.getLogger(__name__) -def decode_bytes(*, input_value: Optional[str | int], field_name: str) -> Optional[bytes]: +def decode_bytes(*, input_value: str | int | None, field_name: str) -> bytes | None: try: if isinstance(input_value, str): return base64.b64decode(input_value) - elif isinstance(input_value, int): - return input_value.to_bytes((input_value.bit_length() + 7) // 8, 'big') + if isinstance(input_value, int): + return input_value.to_bytes((input_value.bit_length() + 7) // 8, "big") except Exception as e: __LOGGER.error("Failed to decode %s: %s", field_name, input_value, exc_info=e) return None diff --git a/src/saic_ismart_client_ng/api/user/__init__.py b/src/saic_ismart_client_ng/api/user/__init__.py index 997c01d..4072269 100644 --- a/src/saic_ismart_client_ng/api/user/__init__.py +++ b/src/saic_ismart_client_ng/api/user/__init__.py @@ -1,12 +1,13 @@ +from __future__ import annotations + from saic_ismart_client_ng.api.base import AbstractSaicApi from saic_ismart_client_ng.api.user.schema import UserTimezoneResp +__all__ = ["UserTimezoneResp"] -class SaicUserApi(AbstractSaicApi): +class SaicUserApi(AbstractSaicApi): async def get_user_timezone(self) -> UserTimezoneResp: return await self.execute_api_call( - "GET", - "/user/timezone", - out_type=UserTimezoneResp + "GET", "/user/timezone", out_type=UserTimezoneResp ) diff --git a/src/saic_ismart_client_ng/api/user/schema.py b/src/saic_ismart_client_ng/api/user/schema.py index 6972eca..df07096 100644 --- a/src/saic_ismart_client_ng/api/user/schema.py +++ b/src/saic_ismart_client_ng/api/user/schema.py @@ -1,7 +1,8 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import Optional @dataclass class UserTimezoneResp: - timezone: Optional[str] = None + timezone: str | None = None diff --git a/src/saic_ismart_client_ng/api/vehicle/__init__.py b/src/saic_ismart_client_ng/api/vehicle/__init__.py index 36245ee..f48547a 100644 --- a/src/saic_ismart_client_ng/api/vehicle/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/__init__.py @@ -1,15 +1,45 @@ +from __future__ import annotations + import tenacity from saic_ismart_client_ng.api.base import AbstractSaicApi -from saic_ismart_client_ng.api.vehicle.schema import VehicleListResp, \ - VehicleStatusResp, \ - VehicleControlReq, VehicleControlResp, RvcParams, RvcReqType, RvcParamsId +from saic_ismart_client_ng.api.vehicle.schema import ( + BasicVehicleStatus, + ExtendedVehicleStatus, + RvcParams, + RvcParamsId, + RvcReqType, + SubAccount, + VehicleControlReq, + VehicleControlResp, + VehicleListResp, + VehicleModelConfiguration, + VehicleStatusResp, + VinInfo, +) from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +__all__ = [ + "BasicVehicleStatus", + "ExtendedVehicleStatus", + "RvcParams", + "RvcParamsId", + "RvcReqType", + "SubAccount", + "VehicleControlReq", + "VehicleControlResp", + "VehicleListResp", + "VehicleModelConfiguration", + "VehicleStatusResp", + "VinInfo", +] + class SaicVehicleApi(AbstractSaicApi): async def vehicle_list(self) -> VehicleListResp: - return await self.execute_api_call("GET", "/vehicle/list", out_type=VehicleListResp) + return await self.execute_api_call( + "GET", "/vehicle/list", out_type=VehicleListResp + ) async def get_vehicle_status(self, vin: str) -> VehicleStatusResp: return await self.execute_api_call_with_event_id( @@ -22,7 +52,9 @@ async def get_vehicle_status(self, vin: str) -> VehicleStatusResp: out_type=VehicleStatusResp, ) - async def send_vehicle_control_command(self, body: VehicleControlReq, vin: str) -> VehicleControlResp: + async def send_vehicle_control_command( + self, body: VehicleControlReq, vin: str + ) -> VehicleControlResp: body.vin = sha256_hex_digest(vin) return await self.execute_api_call_with_event_id( "POST", @@ -33,22 +65,26 @@ async def send_vehicle_control_command(self, body: VehicleControlReq, vin: str) ) async def control_find_my_car( - self, - vin: str, - *, - should_stop: bool = False, - with_horn: bool = True, - with_lights: bool = True + self, + vin: str, + *, + should_stop: bool = False, + with_horn: bool = True, + with_lights: bool = True, ) -> VehicleControlResp: if should_stop: with_horn = False with_lights = False request_type = RvcReqType.FIND_MY_CAR params = [ - RvcParams(RvcParamsId.FIND_MY_CAR_ENABLE, b'\x00' if should_stop else b'\x01'), - RvcParams(RvcParamsId.FIND_MY_CAR_HORN, b'\x01' if with_horn else b'\x00'), - RvcParams(RvcParamsId.FIND_MY_CAR_LIGHTS, b'\x01' if with_lights else b'\x00'), - RvcParams(RvcParamsId.PARAMS_MAX, b'\x00\x00\x00\x00'), + RvcParams( + RvcParamsId.FIND_MY_CAR_ENABLE, b"\x00" if should_stop else b"\x01" + ), + RvcParams(RvcParamsId.FIND_MY_CAR_HORN, b"\x01" if with_horn else b"\x00"), + RvcParams( + RvcParamsId.FIND_MY_CAR_LIGHTS, b"\x01" if with_lights else b"\x00" + ), + RvcParams(RvcParamsId.PARAMS_MAX, b"\x00\x00\x00\x00"), ] command = VehicleControlReq( rvc_req_type=request_type, diff --git a/src/saic_ismart_client_ng/api/vehicle/alarm/__init__.py b/src/saic_ismart_client_ng/api/vehicle/alarm/__init__.py index 0a0c6b8..c7b3e93 100644 --- a/src/saic_ismart_client_ng/api/vehicle/alarm/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/alarm/__init__.py @@ -1,26 +1,32 @@ -from saic_ismart_client_ng import SaicVehicleApi -from saic_ismart_client_ng.api.vehicle.alarm.schema import AlarmSwitchReq, AlarmSwitchResp, AlarmType, AlarmSwitch +from __future__ import annotations + +from saic_ismart_client_ng.api.vehicle import SaicVehicleApi +from saic_ismart_client_ng.api.vehicle.alarm.schema import ( + AlarmSwitch, + AlarmSwitchReq, + AlarmSwitchResp, + AlarmType, +) from saic_ismart_client_ng.crypto_utils import sha256_hex_digest class SaicVehicleAlarmApi(SaicVehicleApi): - async def get_alarm_switch(self, vin) -> AlarmSwitchResp: + async def get_alarm_switch(self, vin: str) -> AlarmSwitchResp: return await self.execute_api_call( "GET", "/vehicle/alarmSwitch", out_type=AlarmSwitchResp, - params={ - "vin": sha256_hex_digest(vin) - } + params={"vin": sha256_hex_digest(vin)}, ) - async def set_alarm_switches(self, alarm_switches: [AlarmType], vin: str) -> None: + async def set_alarm_switches( + self, alarm_switches: list[AlarmType], vin: str + ) -> None: actual_switches = [ AlarmSwitch(alarmType=alarm_type.value, alarmSwitch=1, functionSwitch=1) for alarm_type in alarm_switches ] body = AlarmSwitchReq( - alarmSwitchList=actual_switches, - vin=sha256_hex_digest(vin) + alarmSwitchList=actual_switches, vin=sha256_hex_digest(vin) ) - return await self.execute_api_call("PUT", "/vehicle/alarmSwitch", body=body) + await self.execute_api_call_no_result("PUT", "/vehicle/alarmSwitch", body=body) diff --git a/src/saic_ismart_client_ng/api/vehicle/alarm/schema.py b/src/saic_ismart_client_ng/api/vehicle/alarm/schema.py index 27a3dfe..4d6476d 100644 --- a/src/saic_ismart_client_ng/api/vehicle/alarm/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/alarm/schema.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from dataclasses import dataclass, field from enum import Enum -from typing import List class AlarmType(Enum): @@ -11,17 +12,17 @@ class AlarmType(Enum): @dataclass class AlarmSwitch: - alarmType: int = None - functionSwitch: int = None - alarmSwitch: int = None + alarmType: int | None = None + functionSwitch: int | None = None + alarmSwitch: int | None = None @dataclass class AlarmSwitchResp: - alarmSwitchList: List[AlarmSwitch] = field(default_factory=list) + alarmSwitchList: list[AlarmSwitch] = field(default_factory=list) @dataclass class AlarmSwitchReq: vin: str - alarmSwitchList: List[AlarmSwitch] = field(default_factory=list) + alarmSwitchList: list[AlarmSwitch] = field(default_factory=list) diff --git a/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py b/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py index 5e70de2..fb47aa5 100644 --- a/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py @@ -1,73 +1,66 @@ -from typing import Optional - -from saic_ismart_client_ng import SaicVehicleApi -from saic_ismart_client_ng.api.vehicle import VehicleControlResp, RvcParams, RvcParamsId, VehicleControlReq, RvcReqType +from __future__ import annotations + +from saic_ismart_client_ng.api.vehicle import ( + RvcParams, + RvcParamsId, + RvcReqType, + SaicVehicleApi, + VehicleControlReq, + VehicleControlResp, +) from saic_ismart_client_ng.crypto_utils import sha256_hex_digest -# FIXME: This needs to be refactored according to the logic below class SaicVehicleClimateApi(SaicVehicleApi): - """ - currentTempType = 0 is ATC, 1 is ETC (T11 option code is 2) - if(AirConditionerViewModel.this.currentTempType == 0) { - AirConditionerViewModel.this.sendAcTemp("0", AirTempUtils.getTempToClict(AirConditionerViewModel.this.temp_current, AirConditionerViewModel.this.currentVehicle.getSeries())); - return; - } - - if(((Boolean)AirConditionerViewModel.this.heating.get()).booleanValue()) { - AirConditionerViewModel.this.sendAcTempToETC("0", 4); - return; - } - - if(((Boolean)AirConditionerViewModel.this.refrigerate.get()).booleanValue()) { - AirConditionerViewModel.this.sendAcTempToETC("0", 3); - return; - } - - if(((Boolean)AirConditionerViewModel.this.acBlowing.get()).booleanValue()) { - AirConditionerViewModel.this.sendAcTempToETC("0", 1); - return; - } - AirConditionerViewModel.this.sendAcTempToETC("0", 0); - """ - - async def start_ac(self, vin: str, *, temperature_idx=8) -> VehicleControlResp: - return await self.control_climate(vin, fan_speed=2, ac_on=None, temperature_idx=temperature_idx) + async def start_ac( + self, vin: str, *, temperature_idx: int = 8 + ) -> VehicleControlResp: + return await self.control_climate( + vin, fan_speed=2, ac_on=None, temperature_idx=temperature_idx + ) async def stop_ac(self, vin: str) -> VehicleControlResp: - return await self.control_climate(vin, fan_speed=0, ac_on=False, temperature_idx=0) + return await self.control_climate( + vin, fan_speed=0, ac_on=False, temperature_idx=0 + ) async def start_ac_blowing(self, vin: str) -> VehicleControlResp: - return await self.control_climate(vin, fan_speed=1, ac_on=False, temperature_idx=0) + return await self.control_climate( + vin, fan_speed=1, ac_on=False, temperature_idx=0 + ) async def start_front_defrost(self, vin: str) -> VehicleControlResp: - return await self.control_climate(vin, fan_speed=5, ac_on=True, temperature_idx=8) + return await self.control_climate( + vin, fan_speed=5, ac_on=True, temperature_idx=8 + ) async def control_climate( - self, - vin: str, - *, - fan_speed: int = 5, - ac_on: Optional[bool] = True, - temperature_idx: int = 8 + self, + vin: str, + *, + fan_speed: int = 5, + ac_on: bool | None = True, + temperature_idx: int = 8, ) -> VehicleControlResp: if fan_speed == 0: ac_on = False temperature_idx = 8 - rvc_params = [ - RvcParams(RvcParamsId.FAN_SPEED, fan_speed.to_bytes(1, 'big')) - ] + rvc_params = [RvcParams(RvcParamsId.FAN_SPEED, fan_speed.to_bytes(1, "big"))] if fan_speed > 0 or temperature_idx == 0: - param_fan_speed = RvcParams(RvcParamsId.TEMPERATURE, temperature_idx.to_bytes(1, 'big')) + param_fan_speed = RvcParams( + RvcParamsId.TEMPERATURE, temperature_idx.to_bytes(1, "big") + ) rvc_params.append(param_fan_speed) if ac_on is not None: - param_ac_on_off = RvcParams(RvcParamsId.AC_ON_OFF, b'\x01' if ac_on else b'\x00') + param_ac_on_off = RvcParams( + RvcParamsId.AC_ON_OFF, b"\x01" if ac_on else b"\x00" + ) rvc_params.append(param_ac_on_off) - rvc_params.append(RvcParams(RvcParamsId.PARAMS_MAX, b'\x00\x00\x00\x00')) + rvc_params.append(RvcParams(RvcParamsId.PARAMS_MAX, b"\x00\x00\x00\x00")) body = VehicleControlReq( rvc_req_type=RvcReqType.CLIMATE, @@ -78,16 +71,16 @@ async def control_climate( return await self.send_vehicle_control_command(body, vin) async def control_heated_seats( - self, - vin: str, - *, - left_side_level: int = 0, - right_side_level: int = 0 + self, vin: str, *, left_side_level: int = 0, right_side_level: int = 0 ) -> VehicleControlResp: rcv_params = [ - RvcParams(RvcParamsId.HEATED_SEAT_DRIVER, left_side_level.to_bytes(1, 'big')), - RvcParams(RvcParamsId.HEATED_SEAT_PASSENGER, right_side_level.to_bytes(1, 'big')), - RvcParams(RvcParamsId.PARAMS_MAX, b'\x00\x00\x00\x00') + RvcParams( + RvcParamsId.HEATED_SEAT_DRIVER, left_side_level.to_bytes(1, "big") + ), + RvcParams( + RvcParamsId.HEATED_SEAT_PASSENGER, right_side_level.to_bytes(1, "big") + ), + RvcParams(RvcParamsId.PARAMS_MAX, b"\x00\x00\x00\x00"), ] body = VehicleControlReq( rvc_req_type=RvcReqType.HEATED_SEATS, @@ -96,10 +89,14 @@ async def control_heated_seats( ) return await self.send_vehicle_control_command(body, vin) - async def control_rear_window_heat(self, vin: str, *, enable: bool) -> VehicleControlResp: + async def control_rear_window_heat( + self, vin: str, *, enable: bool + ) -> VehicleControlResp: rvc_params = [ - RvcParams(RvcParamsId.REMOTE_HEAT_REAR_WINDOW, b'\x01' if enable else b'\x00'), - RvcParams(RvcParamsId.PARAMS_MAX, b'\x00\x00\x00\x00') + RvcParams( + RvcParamsId.REMOTE_HEAT_REAR_WINDOW, b"\x01" if enable else b"\x00" + ), + RvcParams(RvcParamsId.PARAMS_MAX, b"\x00\x00\x00\x00"), ] body = VehicleControlReq( rvc_req_type=RvcReqType.REMOTE_HEAT_REAR_WINDOW, diff --git a/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py b/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py index c7c6ae4..befd5cc 100644 --- a/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py @@ -1,41 +1,56 @@ -from typing import Optional +from __future__ import annotations -from saic_ismart_client_ng import SaicVehicleApi +from saic_ismart_client_ng.api.vehicle import SaicVehicleApi from saic_ismart_client_ng.api.vehicle.locks.schema import VehicleLockId -from saic_ismart_client_ng.api.vehicle.schema import VehicleControlReq, VehicleControlResp, RvcParams, \ - RvcReqType, RvcParamsId +from saic_ismart_client_ng.api.vehicle.schema import ( + RvcParams, + RvcParamsId, + RvcReqType, + VehicleControlReq, + VehicleControlResp, +) from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +from saic_ismart_client_ng.exceptions import SaicApiException +__all__ = ["VehicleLockId"] -class SaicVehicleLocksApi(SaicVehicleApi): +class SaicVehicleLocksApi(SaicVehicleApi): async def lock_vehicle(self, vin: str) -> VehicleControlResp: return await self.control_vehicle_locks(vin, should_lock=True) async def unlock_vehicle(self, vin: str) -> VehicleControlResp: - return await self.control_vehicle_locks(vin, should_lock=False, lock_id=VehicleLockId.DOORS) + return await self.control_vehicle_locks( + vin, should_lock=False, lock_id=VehicleLockId.DOORS + ) async def open_tailgate(self, vin: str) -> VehicleControlResp: - return await self.control_vehicle_locks(vin, should_lock=False, lock_id=VehicleLockId.TAILGATE) + return await self.control_vehicle_locks( + vin, should_lock=False, lock_id=VehicleLockId.TAILGATE + ) async def control_vehicle_locks( - self, - vin: str, - *, - should_lock: bool, - lock_id: Optional[VehicleLockId] = None, + self, + vin: str, + *, + should_lock: bool, + lock_id: VehicleLockId | None = None, ) -> VehicleControlResp: if should_lock: request_type = RvcReqType.CLOSE_LOCKS params = None else: + if lock_id is None: + raise SaicApiException("Can't unlock without lock_id") request_type = RvcReqType.OPEN_LOCKS params = [ - RvcParams(RvcParamsId.UNK_4, b'\x00'), - RvcParams(RvcParamsId.UNK_5, b'\x00'), - RvcParams(RvcParamsId.UNK_6, b'\x00'), - RvcParams(RvcParamsId.LOCK_ID, lock_id.value.to_bytes(1, byteorder='big')), - RvcParams(RvcParamsId.PARAMS_MAX, b'\x00\x00\x00\x00'), + RvcParams(RvcParamsId.UNK_4, b"\x00"), + RvcParams(RvcParamsId.UNK_5, b"\x00"), + RvcParams(RvcParamsId.UNK_6, b"\x00"), + RvcParams( + RvcParamsId.LOCK_ID, lock_id.value.to_bytes(1, byteorder="big") + ), + RvcParams(RvcParamsId.PARAMS_MAX, b"\x00\x00\x00\x00"), ] command = VehicleControlReq( rvc_req_type=request_type, diff --git a/src/saic_ismart_client_ng/api/vehicle/locks/schema.py b/src/saic_ismart_client_ng/api/vehicle/locks/schema.py index a3902a5..4f84051 100644 --- a/src/saic_ismart_client_ng/api/vehicle/locks/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/locks/schema.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum diff --git a/src/saic_ismart_client_ng/api/vehicle/schema.py b/src/saic_ismart_client_ng/api/vehicle/schema.py index 28184c3..eb2667d 100644 --- a/src/saic_ismart_client_ng/api/vehicle/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/schema.py @@ -1,130 +1,138 @@ +from __future__ import annotations + import base64 from dataclasses import dataclass, field from enum import Enum -from typing import List, Optional +from typing import TYPE_CHECKING, Any -from saic_ismart_client_ng.api.schema import GpsPosition from saic_ismart_client_ng.api.serialization_utils import decode_bytes +if TYPE_CHECKING: + from saic_ismart_client_ng.api.schema import GpsPosition + @dataclass class VehicleModelConfiguration: - itemCode: str = None - itemName: str = None - itemValue: str = None + itemCode: str | None = None + itemName: str | None = None + itemValue: str | None = None @dataclass class SubAccount: - authorizationCardType: int = None - btKeyStatus: int = None - locationAuthorization: int = None - modelName: str = None - operationType: int = None - status: int = None - subaccountId: int = None - subscriberId: int = None - userAccount: str = None - userName: str = None - validityEndTime: int = None - validityStartTime: int = None - vin: str = None + authorizationCardType: int | None = None + btKeyStatus: int | None = None + locationAuthorization: int | None = None + modelName: str | None = None + operationType: int | None = None + status: int | None = None + subaccountId: int | None = None + subscriberId: int | None = None + userAccount: str | None = None + userName: str | None = None + validityEndTime: int | None = None + validityStartTime: int | None = None + vin: str | None = None @dataclass class VinInfo: - bindTime: int = None - brandName: str = None - colorName: str = None - isActivate: bool = None - isCurrentVehicle: bool = None - isSubaccount: bool = None - modelName: str = None - modelYear: str = None - name: str = None - series: str = None - vin: str = None - subAccountList: List[SubAccount] = field(default_factory=list) - vehicleModelConfiguration: List[VehicleModelConfiguration] = field(default_factory=list) + bindTime: int | None = None + brandName: str | None = None + colorName: str | None = None + isActivate: bool | None = None + isCurrentVehicle: bool | None = None + isSubaccount: bool | None = None + modelName: str | None = None + modelYear: str | None = None + name: str | None = None + series: str | None = None + vin: str | None = None + subAccountList: list[SubAccount] = field(default_factory=list) + vehicleModelConfiguration: list[VehicleModelConfiguration] = field( + default_factory=list + ) @dataclass class VehicleListResp: - vinList: List[VinInfo] = field(default_factory=list) + vinList: list[VinInfo] = field(default_factory=list) @dataclass class BasicVehicleStatus: - batteryVoltage: int = None - bonnetStatus: int = None - bootStatus: int = None - canBusActive: int = None - clstrDspdFuelLvlSgmt: int = None - currentJourneyId: int = None - currentJourneyDistance: int = None - dippedBeamStatus: int = None - driverDoor: int = None - driverWindow: Optional[int] = None - engineStatus: int = None - extendedData1: Optional[int] = None - extendedData2: Optional[int] = None - exteriorTemperature: int = None - frontLeftSeatHeatLevel: Optional[int] = None - frontLeftTyrePressure: Optional[int] = None - frontRightSeatHeatLevel: Optional[int] = None - frontRightTyrePressure: Optional[int] = None - fuelLevelPrc: int = None - fuelRange: int = None - fuelRangeElec: Optional[int] = None - handBrake: int = None - interiorTemperature: int = None - lastKeySeen: int = None - lockStatus: int = None - mainBeamStatus: int = None - mileage: int = None - passengerDoor: int = None - passengerWindow: Optional[int] = None - powerMode: int = None - rearLeftDoor: int = None - rearLeftTyrePressure: Optional[int] = None - rearLeftWindow: Optional[int] = None - rearRightDoor: int = None - rearRightTyrePressure: Optional[int] = None - rearRightWindow: Optional[int] = None - remoteClimateStatus: int = None - rmtHtdRrWndSt: int = None - sideLightStatus: int = None - steeringHeatLevel: int = None - steeringWheelHeatFailureReason: int = None - sunroofStatus: Optional[int] = None - timeOfLastCANBUSActivity: int = None - vehElecRngDsp: int = None - vehicleAlarmStatus: Optional[int] = None - wheelTyreMonitorStatus: Optional[int] = None + batteryVoltage: int | None = None + bonnetStatus: int | None = None + bootStatus: int | None = None + canBusActive: int | None = None + clstrDspdFuelLvlSgmt: int | None = None + currentJourneyId: int | None = None + currentJourneyDistance: int | None = None + dippedBeamStatus: int | None = None + driverDoor: int | None = None + driverWindow: int | None = None + engineStatus: int | None = None + extendedData1: int | None = None + extendedData2: int | None = None + exteriorTemperature: int | None = None + frontLeftSeatHeatLevel: int | None = None + frontLeftTyrePressure: int | None = None + frontRightSeatHeatLevel: int | None = None + frontRightTyrePressure: int | None = None + fuelLevelPrc: int | None = None + fuelRange: int | None = None + fuelRangeElec: int | None = None + handBrake: int | None = None + interiorTemperature: int | None = None + lastKeySeen: int | None = None + lockStatus: int | None = None + mainBeamStatus: int | None = None + mileage: int | None = None + passengerDoor: int | None = None + passengerWindow: int | None = None + powerMode: int | None = None + rearLeftDoor: int | None = None + rearLeftTyrePressure: int | None = None + rearLeftWindow: int | None = None + rearRightDoor: int | None = None + rearRightTyrePressure: int | None = None + rearRightWindow: int | None = None + remoteClimateStatus: int | None = None + rmtHtdRrWndSt: int | None = None + sideLightStatus: int | None = None + steeringHeatLevel: int | None = None + steeringWheelHeatFailureReason: int | None = None + sunroofStatus: int | None = None + timeOfLastCANBUSActivity: int | None = None + vehElecRngDsp: int | None = None + vehicleAlarmStatus: int | None = None + wheelTyreMonitorStatus: int | None = None @dataclass class ExtendedVehicleStatus: - alertDataSum: list = field(default_factory=list) + alertDataSum: list[Any] = field(default_factory=list) @dataclass class VehicleStatusResp: - basicVehicleStatus: BasicVehicleStatus = None - extendedVehicleStatus: ExtendedVehicleStatus = None - gpsPosition: GpsPosition = None - statusTime: int = None + # pylint: disable=import-outside-toplevel + from saic_ismart_client_ng.api.schema import GpsPosition + + basicVehicleStatus: BasicVehicleStatus | None = None + extendedVehicleStatus: ExtendedVehicleStatus | None = None + gpsPosition: GpsPosition | None = None + statusTime: int | None = None @property def is_parked(self) -> bool: - return ( - self.basicVehicleStatus.engineStatus != 1 - or self.basicVehicleStatus.handBrake + return (v := self.basicVehicleStatus) is not None and ( + v.engineStatus != 1 or v.handBrake == 1 ) @property def is_engine_running(self) -> bool: - return self.basicVehicleStatus.engineStatus == 1 + return (v := self.basicVehicleStatus) is not None and v.engineStatus == 1 class RvcParamsId(Enum): @@ -155,9 +163,9 @@ class RvcParams: paramId: int paramValue: str - def __init__(self, param_id: RvcParamsId, param_value: bytes): + def __init__(self, param_id: RvcParamsId, param_value: bytes) -> None: self.paramId = param_id.value - self.paramValue = base64.b64encode(param_value).decode('utf-8') + self.paramValue = base64.b64encode(param_value).decode("utf-8") class RvcReqType(Enum): @@ -178,32 +186,37 @@ class RvcReqType(Enum): @dataclass class VehicleControlReq: - rvcParams: List[RvcParams] - rvcReqType: Optional[str | int] + rvcParams: list[RvcParams] | None + rvcReqType: str | int | None vin: str - def __init__(self, rvc_params: List[RvcParams], rvc_req_type: RvcReqType, vin: str): + def __init__( + self, rvc_params: list[RvcParams] | None, rvc_req_type: RvcReqType, vin: str + ) -> None: self.rvcParams = rvc_params self.rvcReqType = rvc_req_type.value self.vin = vin @property - def rvc_req_type_decoded(self) -> Optional[bytes]: - return decode_bytes(input_value=self.rvcReqType, field_name='rvcReqType') + def rvc_req_type_decoded(self) -> bytes | None: + return decode_bytes(input_value=self.rvcReqType, field_name="rvcReqType") @dataclass class VehicleControlResp: - basicVehicleStatus: BasicVehicleStatus = None - failureType: int = None - gpsPosition: GpsPosition = None - rvcReqSts: Optional[str | int] = None - rvcReqType: Optional[str | int] = None + # pylint: disable=import-outside-toplevel + from saic_ismart_client_ng.api.schema import GpsPosition + + basicVehicleStatus: BasicVehicleStatus | None = None + failureType: int | None = None + gpsPosition: GpsPosition | None = None + rvcReqSts: str | int | None = None + rvcReqType: str | int | None = None @property - def rvc_req_sts_decoded(self) -> Optional[bytes]: - return decode_bytes(input_value=self.rvcReqSts, field_name='rvcReqSts') + def rvc_req_sts_decoded(self) -> bytes | None: + return decode_bytes(input_value=self.rvcReqSts, field_name="rvcReqSts") @property - def rvc_req_type_decoded(self) -> Optional[bytes]: - return decode_bytes(input_value=self.rvcReqType, field_name='rvcReqType') + def rvc_req_type_decoded(self) -> bytes | None: + return decode_bytes(input_value=self.rvcReqType, field_name="rvcReqType") diff --git a/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py b/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py index 16d0576..dba55f0 100644 --- a/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py @@ -1,19 +1,35 @@ -from saic_ismart_client_ng import SaicVehicleApi -from saic_ismart_client_ng.api.vehicle.schema import VehicleControlReq, VehicleControlResp, RvcParams, RvcReqType, \ - RvcParamsId +from __future__ import annotations + +from saic_ismart_client_ng.api.vehicle import SaicVehicleApi +from saic_ismart_client_ng.api.vehicle.schema import ( + RvcParams, + RvcParamsId, + RvcReqType, + VehicleControlReq, + VehicleControlResp, +) from saic_ismart_client_ng.api.vehicle.windows.schema import VehicleWindowId from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +__all__ = ["VehicleWindowId"] -class SaicVehicleWindowsApi(SaicVehicleApi): - async def control_sunroof(self, vin: str, *, should_open: bool) -> VehicleControlResp: - return await self.control_windows(vin, should_open=should_open, windows=[VehicleWindowId.SUNROOF]) +class SaicVehicleWindowsApi(SaicVehicleApi): + async def control_sunroof( + self, vin: str, *, should_open: bool + ) -> VehicleControlResp: + return await self.control_windows( + vin, should_open=should_open, windows=[VehicleWindowId.SUNROOF] + ) async def close_driver_window(self, vin: str) -> VehicleControlResp: - return await self.control_windows(vin, should_open=False, windows=[VehicleWindowId.DRIVER]) + return await self.control_windows( + vin, should_open=False, windows=[VehicleWindowId.DRIVER] + ) - async def control_windows(self, vin: str, *, should_open: bool, windows: [VehicleWindowId]) -> VehicleControlResp: + async def control_windows( + self, vin: str, *, should_open: bool, windows: list[VehicleWindowId] + ) -> VehicleControlResp: requested_windows = [w.value.value for w in windows] rcv_params = [] for i in [ @@ -21,14 +37,18 @@ async def control_windows(self, vin: str, *, should_open: bool, windows: [Vehicl VehicleWindowId.DRIVER, VehicleWindowId.WINDOW_2, VehicleWindowId.WINDOW_3, - VehicleWindowId.WINDOW_4 + VehicleWindowId.WINDOW_4, ]: if i.value.value in requested_windows: - rcv_params.append(RvcParams(i.value, b'\x01')) + rcv_params.append(RvcParams(i.value, b"\x01")) else: - rcv_params.append(RvcParams(i.value, b'\x00')) + rcv_params.append(RvcParams(i.value, b"\x00")) - rcv_params.append(RvcParams(RvcParamsId.WINDOW_OPEN_CLOSE, b'\x03' if should_open else b'\x00')) + rcv_params.append( + RvcParams( + RvcParamsId.WINDOW_OPEN_CLOSE, b"\x03" if should_open else b"\x00" + ) + ) request = VehicleControlReq( rvc_req_type=RvcReqType.WINDOWS, diff --git a/src/saic_ismart_client_ng/api/vehicle/windows/schema.py b/src/saic_ismart_client_ng/api/vehicle/windows/schema.py index 892d864..bf24cbd 100644 --- a/src/saic_ismart_client_ng/api/vehicle/windows/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/windows/schema.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import Enum from saic_ismart_client_ng.api.vehicle import RvcParamsId diff --git a/src/saic_ismart_client_ng/api/vehicle_charging/__init__.py b/src/saic_ismart_client_ng/api/vehicle_charging/__init__.py index c88da39..357954c 100644 --- a/src/saic_ismart_client_ng/api/vehicle_charging/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle_charging/__init__.py @@ -1,15 +1,59 @@ -from datetime import datetime, timedelta +from __future__ import annotations + +from datetime import datetime, time, timedelta from saic_ismart_client_ng.api.base import AbstractSaicApi -from saic_ismart_client_ng.api.vehicle_charging.schema import ChargeStatusResp, ChargingControlRequest, \ - ChargingControlResp, ScheduledChargingRequest, ChargingPtcHeatRequest, ChargingSettingRequest, ChrgPtcHeatResp, \ - ScheduledChargingResp, ChargingSettingResp, TargetBatteryCode, ChargeCurrentLimitCode, ScheduledChargingMode, \ - ScheduledBatteryHeatingRequest, ScheduledBatteryHeatingResp, ChrgMgmtDataResp +from saic_ismart_client_ng.api.vehicle_charging.schema import ( + BmsChargingStatusCode, + ChargeCurrentLimitCode, + ChargeStatusResp, + ChargingControlRequest, + ChargingControlResp, + ChargingPtcHeatRequest, + ChargingSettingRequest, + ChargingSettingResp, + ChargingStatus, + ChargingStopReason, + ChrgMgmtData, + ChrgMgmtDataResp, + ChrgPtcHeatResp, + HeatingStopReason, + RvsChargeStatus, + ScheduledBatteryHeatingRequest, + ScheduledBatteryHeatingResp, + ScheduledChargingMode, + ScheduledChargingRequest, + ScheduledChargingResp, + TargetBatteryCode, +) from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +__all__ = [ + "BmsChargingStatusCode", + "ChargeCurrentLimitCode", + "ChargeStatusResp", + "ChargingControlRequest", + "ChargingControlResp", + "ChargingPtcHeatRequest", + "ChargingSettingRequest", + "ChargingSettingResp", + "ChargingStatus", + "ChargingStopReason", + "ChrgMgmtData", + "ChrgMgmtDataResp", + "ChrgPtcHeatResp", + "HeatingStopReason", + "RvsChargeStatus", + "ScheduledBatteryHeatingRequest", + "ScheduledBatteryHeatingResp", + "ScheduledChargingMode", + "ScheduledChargingRequest", + "ScheduledChargingResp", + "TargetBatteryCode", +] -class SaicVehicleChargingApi(AbstractSaicApi): +class SaicVehicleChargingApi(AbstractSaicApi): async def get_vehicle_charging_status(self, vin: str) -> ChargeStatusResp: return await self.execute_api_call_with_event_id( "GET", @@ -17,7 +61,7 @@ async def get_vehicle_charging_status(self, vin: str) -> ChargeStatusResp: params={ "vin": sha256_hex_digest(vin), }, - out_type=ChargeStatusResp + out_type=ChargeStatusResp, ) async def get_vehicle_charging_management_data(self, vin: str) -> ChrgMgmtDataResp: @@ -27,19 +71,20 @@ async def get_vehicle_charging_management_data(self, vin: str) -> ChrgMgmtDataRe params={ "vin": sha256_hex_digest(vin), }, - out_type=ChrgMgmtDataResp + out_type=ChrgMgmtDataResp, ) - async def send_vehicle_charging_control(self, vin: str, body: ChargingControlRequest) -> ChargingControlResp: + async def send_vehicle_charging_control( + self, vin: str, body: ChargingControlRequest + ) -> ChargingControlResp: body.vin = sha256_hex_digest(vin) return await self.execute_api_call_with_event_id( - "POST", - "/vehicle/charging/control", - body=body, - out_type=ChargingControlResp + "POST", "/vehicle/charging/control", body=body, out_type=ChargingControlResp ) - async def control_charging_port_lock(self, vin: str, *, unlock: bool) -> ChargingControlResp: + async def control_charging_port_lock( + self, vin: str, *, unlock: bool + ) -> ChargingControlResp: body = ChargingControlRequest( chrgCtrlReq=0, tboxV2XReq=0, @@ -47,7 +92,9 @@ async def control_charging_port_lock(self, vin: str, *, unlock: bool) -> Chargin ) return await self.send_vehicle_charging_control(vin, body) - async def control_charging(self, vin: str, *, stop_charging: bool) -> ChargingControlResp: + async def control_charging( + self, vin: str, *, stop_charging: bool + ) -> ChargingControlResp: body = ChargingControlRequest( chrgCtrlReq=2 if stop_charging else 1, tboxV2XReq=0, @@ -64,25 +111,18 @@ async def control_v2x(self, vin: str, *, stop_v2x: bool) -> ChargingControlResp: return await self.send_vehicle_charging_control(vin, body) async def send_vehicle_charging_reservation( - self, - vin: str, - body: ScheduledChargingRequest + self, vin: str, body: ScheduledChargingRequest ) -> ScheduledChargingResp: body.vin = sha256_hex_digest(vin) return await self.execute_api_call_with_event_id( "POST", "/vehicle/charging/reservation", body=body, - out_type=ScheduledChargingResp + out_type=ScheduledChargingResp, ) async def set_schedule_charging( - self, - vin: str, - *, - start_time: datetime.time, - end_time: datetime.time, - mode: ScheduledChargingMode + self, vin: str, *, start_time: time, end_time: time, mode: ScheduledChargingMode ) -> ScheduledChargingResp: start_hour = start_time.hour start_minute = start_time.minute @@ -99,31 +139,31 @@ async def set_schedule_charging( ) return await self.send_vehicle_charging_reservation(vin, body) - async def get_vehicle_battery_heating_schedule(self, vin: str) -> ScheduledBatteryHeatingResp: + async def get_vehicle_battery_heating_schedule( + self, vin: str + ) -> ScheduledBatteryHeatingResp: return await self.execute_api_call( "GET", "/charging/batteryHeating", params={ "vin": sha256_hex_digest(vin), }, - out_type=ScheduledBatteryHeatingResp + out_type=ScheduledBatteryHeatingResp, ) async def send_vehicle_battery_heating_schedule( - self, - vin: str, - body: ScheduledBatteryHeatingRequest + self, vin: str, body: ScheduledBatteryHeatingRequest ) -> None: body.vin = sha256_hex_digest(vin) - return await self.execute_api_call( + await self.execute_api_call_no_result( "POST", "/charging/batteryHeating", body=body, ) async def disable_schedule_battery_heating( - self, - vin: str, + self, + vin: str, ) -> None: body = ScheduledBatteryHeatingRequest( vin=sha256_hex_digest(vin), @@ -133,16 +173,13 @@ async def disable_schedule_battery_heating( return await self.send_vehicle_battery_heating_schedule(vin, body) async def enable_schedule_battery_heating( - self, - vin: str, - *, - start_time: datetime.time, + self, + vin: str, + *, + start_time: time, ) -> None: start_date = datetime.now().replace( - hour=start_time.hour, - minute=start_time.minute, - second=0, - microsecond=0 + hour=start_time.hour, minute=start_time.minute, second=0, microsecond=0 ) if start_date < datetime.now(): start_date = start_date + timedelta(days=1) @@ -153,29 +190,28 @@ async def enable_schedule_battery_heating( ) return await self.send_vehicle_battery_heating_schedule(vin, body) - async def send_vehicle_charging_ptc_heat(self, vin: str, body: ChargingPtcHeatRequest) -> ChrgPtcHeatResp: + async def send_vehicle_charging_ptc_heat( + self, vin: str, body: ChargingPtcHeatRequest + ) -> ChrgPtcHeatResp: body.vin = sha256_hex_digest(vin) return await self.execute_api_call_with_event_id( - "POST", - "/vehicle/charging/ptcHeat", - body=body, - out_type=ChrgPtcHeatResp + "POST", "/vehicle/charging/ptcHeat", body=body, out_type=ChrgPtcHeatResp ) - async def control_battery_heating(self, vin: str, *, enable: bool) -> ChrgPtcHeatResp: + async def control_battery_heating( + self, vin: str, *, enable: bool + ) -> ChrgPtcHeatResp: body = ChargingPtcHeatRequest( - ptcHeatReq=1 if enable else 2, - vin=sha256_hex_digest(vin) + ptcHeatReq=1 if enable else 2, vin=sha256_hex_digest(vin) ) return await self.send_vehicle_charging_ptc_heat(vin, body) - async def send_vehicle_charging_settings(self, vin: str, body: ChargingSettingRequest) -> ChargingSettingResp: + async def send_vehicle_charging_settings( + self, vin: str, body: ChargingSettingRequest + ) -> ChargingSettingResp: body.vin = sha256_hex_digest(vin) return await self.execute_api_call_with_event_id( - "POST", - "/vehicle/charging/setting", - body=body, - out_type=ChargingSettingResp + "POST", "/vehicle/charging/setting", body=body, out_type=ChargingSettingResp ) async def get_vehicle_charging_settings(self, vin: str) -> ChargingSettingResp: @@ -183,33 +219,31 @@ async def get_vehicle_charging_settings(self, vin: str) -> ChargingSettingResp: altngChrgCrntReq=0, onBdChrgTrgtSOCReq=0, tboxV2XSpSOCReq=0, - vin=sha256_hex_digest(vin) + vin=sha256_hex_digest(vin), ) return await self.send_vehicle_charging_settings(vin, body) async def set_target_battery_soc( - self, - vin: str, - target_soc: TargetBatteryCode, - charge_current_limit: ChargeCurrentLimitCode = ChargeCurrentLimitCode.C_IGNORE, + self, + vin: str, + target_soc: TargetBatteryCode, + charge_current_limit: ChargeCurrentLimitCode = ChargeCurrentLimitCode.C_IGNORE, ) -> ChargingSettingResp: body = ChargingSettingRequest( onBdChrgTrgtSOCReq=target_soc.value, altngChrgCrntReq=charge_current_limit.value, tboxV2XSpSOCReq=0, - vin=sha256_hex_digest(vin) + vin=sha256_hex_digest(vin), ) return await self.send_vehicle_charging_settings(vin, body) async def set_v2x_target_battery_soc( - self, - vin: str, - target_soc: TargetBatteryCode + self, vin: str, target_soc: TargetBatteryCode ) -> ChargingSettingResp: body = ChargingSettingRequest( onBdChrgTrgtSOCReq=0, altngChrgCrntReq=0, tboxV2XSpSOCReq=target_soc.value, - vin=sha256_hex_digest(vin) + vin=sha256_hex_digest(vin), ) return await self.send_vehicle_charging_settings(vin, body) diff --git a/src/saic_ismart_client_ng/api/vehicle_charging/schema.py b/src/saic_ismart_client_ng/api/vehicle_charging/schema.py index 5fad1b0..28ab21b 100644 --- a/src/saic_ismart_client_ng/api/vehicle_charging/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle_charging/schema.py @@ -1,11 +1,15 @@ -import datetime +from __future__ import annotations + from dataclasses import dataclass +import datetime from enum import Enum -from typing import Optional +from typing import TYPE_CHECKING -from saic_ismart_client_ng.api.schema import GpsPosition from saic_ismart_client_ng.api.serialization_utils import decode_bytes +if TYPE_CHECKING: + from saic_ismart_client_ng.api.schema import GpsPosition + class ScheduledChargingMode(Enum): DISABLED = 2 @@ -21,7 +25,7 @@ class ChargeCurrentLimitCode(Enum): C_MAX = 4 @staticmethod - def to_code(limit: str): + def to_code(limit: str) -> ChargeCurrentLimitCode: match limit.upper(): case "6A": return ChargeCurrentLimitCode.C_6A @@ -32,7 +36,8 @@ def to_code(limit: str): case "MAX": return ChargeCurrentLimitCode.C_MAX case _: - raise ValueError(f'Unknown charge current limit: {limit}') + msg = f"Unknown charge current limit: {limit}" + raise ValueError(msg) @property def limit(self) -> str: @@ -46,7 +51,8 @@ def limit(self) -> str: case ChargeCurrentLimitCode.C_MAX: return "Max" case _: - raise ValueError(f'Unknown charge current limit code: {self}') + msg = f"Unknown charge current limit code: {self}" + raise ValueError(msg) class BmsChargingStatusCode(Enum): @@ -65,7 +71,7 @@ class BmsChargingStatusCode(Enum): CHARGING_12 = 12 @staticmethod - def to_code(code: int): + def to_code(code: int) -> BmsChargingStatusCode | None: try: return BmsChargingStatusCode(code) except ValueError: @@ -73,14 +79,17 @@ def to_code(code: int): class HeatingStopReason(Enum): + NO_REASON = 0 + UNKNOWN_1 = 1 LOW_BATTERY = 2 REACHED_STOP_CONDITION = 3 UNNECESSARY = 4 REACHED_STOP_TIME = 5 + UNKNOWN_6 = 6 HEATING_SYSTEM_FAILURE = 7 @staticmethod - def to_code(code: int): + def to_code(code: int) -> HeatingStopReason | None: try: return HeatingStopReason(code) except ValueError: @@ -96,7 +105,7 @@ class ChargingStopReason(Enum): OTHER_REASON = 5 @staticmethod - def to_code(code: int): + def to_code(code: int) -> ChargingStopReason: try: return ChargingStopReason(code) except ValueError: @@ -114,7 +123,8 @@ class TargetBatteryCode(Enum): P_100 = 7 @property - def percentage(self) -> int: + # pylint: disable=too-many-return-statements + def percentage(self) -> int: # noqa: PLR0911 match self: case TargetBatteryCode.P_40: return 40 @@ -131,10 +141,12 @@ def percentage(self) -> int: case TargetBatteryCode.P_100: return 100 case _: - raise ValueError(f'Unknown target battery code: {self}') + msg = f"Unknown target battery code: {self}" + raise ValueError(msg) @staticmethod - def from_percentage(percentage: int): + # pylint: disable=too-many-return-statements + def from_percentage(percentage: int) -> TargetBatteryCode: # noqa: PLR0911 match percentage: case 40: return TargetBatteryCode.P_40 @@ -151,107 +163,117 @@ def from_percentage(percentage: int): case 100: return TargetBatteryCode.P_100 case _: # default - raise ValueError(f'Unknown target battery percentage: {percentage}') + msg = f"Unknown target battery percentage: {percentage}" + raise ValueError(msg) @dataclass class ChargingStatus: - chargingCurrent: int = None - chargingDuration: int = None - chargingElectricityPhase: int = None - chargingGunState: int = None - chargingPileID: str = None - chargingPileSupplier: str = None - chargingState: int = None - chargingTimeLevelPrc: int = None - chargingType: int = None - chargingVoltage: int = None - endTime: int = None - fotaLowestVoltage: int = None - fuelRangeElec: int = None - lastChargeEndingPower: int = None - mileage: int = None - mileageOfDay: int = None - mileageSinceLastCharge: int = None - powerLevelPrc: int = None - powerUsageOfDay: int = None - powerUsageSinceLastCharge: int = None - realtimePower: int = None - startTime: int = None - staticEnergyConsumption: int = None - totalBatteryCapacity: int = None - workingCurrent: int = None - workingVoltage: int = None + chargingCurrent: int | None = None + chargingDuration: int | None = None + chargingElectricityPhase: int | None = None + chargingGunState: int | None = None + chargingPileID: str | None = None + chargingPileSupplier: str | None = None + chargingState: int | None = None + chargingTimeLevelPrc: int | None = None + chargingType: int | None = None + chargingVoltage: int | None = None + endTime: int | None = None + fotaLowestVoltage: int | None = None + fuelRangeElec: int | None = None + lastChargeEndingPower: int | None = None + mileage: int | None = None + mileageOfDay: int | None = None + mileageSinceLastCharge: int | None = None + powerLevelPrc: int | None = None + powerUsageOfDay: int | None = None + powerUsageSinceLastCharge: int | None = None + realtimePower: int | None = None + startTime: int | None = None + staticEnergyConsumption: int | None = None + totalBatteryCapacity: int | None = None + workingCurrent: int | None = None + workingVoltage: int | None = None @dataclass class ChargeStatusResp: - chargingStatus: ChargingStatus = None - gpsPosition: GpsPosition = None - statusTime: int = None + # pylint: disable=import-outside-toplevel + from saic_ismart_client_ng.api.schema import GpsPosition + + chargingStatus: ChargingStatus | None = None + gpsPosition: GpsPosition | None = None + statusTime: int | None = None @dataclass class ChrgMgmtData: - bmsAdpPubChrgSttnDspCmd: int = None - bmsAltngChrgCrntDspCmd: int = None - bmsChrgCtrlDspCmd: int = None - bmsChrgOtptCrntReq: int = None - bmsChrgOtptCrntReqV: Optional[int] = None - bmsChrgSpRsn: int = None - bmsChrgSts: int = None - bmsDsChrgSpRsn: Optional[int] = None - bmsEstdElecRng: int = None - bmsOnBdChrgTrgtSOCDspCmd: int = None - bmsPackCrnt: int = None - bmsPackCrntV: Optional[int] = None - bmsPackSOCDsp: int = None - bmsPackVol: int = None - bmsPTCHeatReqDspCmd: int = None - bmsPTCHeatResp: Optional[int] = None - bmsPTCHeatSpRsn: Optional[int] = None - bmsReserCtrlDspCmd: int = None - bmsReserSpHourDspCmd: int = None - bmsReserSpMintueDspCmd: int = None - bmsReserStHourDspCmd: int = None - bmsReserStMintueDspCmd: int = None - ccuEleccLckCtrlDspCmd: Optional[int] = None - ccuOffBdChrgrPlugOn: Optional[int] = None - ccuOnbdChrgrPlugOn: Optional[int] = None - chrgngAddedElecRng: Optional[int] = None - chrgngAddedElecRngV: Optional[int] = None - chrgngDoorOpenCnd: Optional[int] = None - chrgngDoorPosSts: Optional[int] = None - chrgngRmnngTime: int = None - chrgngRmnngTimeV: int = None - chrgngSpdngTime: Optional[int] = None - chrgngSpdngTimeV: Optional[int] = None - clstrElecRngToEPT: int = None - disChrgngRmnngTime: Optional[int] = None - disChrgngRmnngTimeV: Optional[int] = None - imcuChrgngEstdElecRng: Optional[int] = None - imcuChrgngEstdElecRngV: Optional[int] = None - imcuDschrgngEstdElecRng: Optional[int] = None - imcuDschrgngEstdElecRngV: Optional[int] = None - imcuVehElecRng: Optional[int] = None - imcuVehElecRngV: Optional[int] = None - onBdChrgrAltrCrntInptCrnt: Optional[int] = None - onBdChrgrAltrCrntInptVol: Optional[int] = None - - @property - def decoded_current(self) -> float: - return self.bmsPackCrnt * 0.05 - 1000.0 - - @property - def decoded_voltage(self) -> float: - return self.bmsPackVol * 0.25 - - @property - def decoded_power(self) -> float: - return self.decoded_current * self.decoded_voltage / 1000.0 - - @property - def charge_target_soc(self) -> Optional[TargetBatteryCode]: + bmsAdpPubChrgSttnDspCmd: int | None = None + bmsAltngChrgCrntDspCmd: int | None = None + bmsChrgCtrlDspCmd: int | None = None + bmsChrgOtptCrntReq: int | None = None + bmsChrgOtptCrntReqV: int | None = None + bmsChrgSpRsn: int | None = None + bmsChrgSts: int | None = None + bmsDsChrgSpRsn: int | None = None + bmsEstdElecRng: int | None = None + bmsOnBdChrgTrgtSOCDspCmd: int | None = None + bmsPackCrnt: int | None = None + bmsPackCrntV: int | None = None + bmsPackSOCDsp: int | None = None + bmsPackVol: int | None = None + bmsPTCHeatReqDspCmd: int | None = None + bmsPTCHeatResp: int | None = None + bmsPTCHeatSpRsn: int | None = None + bmsReserCtrlDspCmd: int | None = None + bmsReserSpHourDspCmd: int | None = None + bmsReserSpMintueDspCmd: int | None = None + bmsReserStHourDspCmd: int | None = None + bmsReserStMintueDspCmd: int | None = None + ccuEleccLckCtrlDspCmd: int | None = None + ccuOffBdChrgrPlugOn: int | None = None + ccuOnbdChrgrPlugOn: int | None = None + chrgngAddedElecRng: int | None = None + chrgngAddedElecRngV: int | None = None + chrgngDoorOpenCnd: int | None = None + chrgngDoorPosSts: int | None = None + chrgngRmnngTime: int | None = None + chrgngRmnngTimeV: int | None = None + chrgngSpdngTime: int | None = None + chrgngSpdngTimeV: int | None = None + clstrElecRngToEPT: int | None = None + disChrgngRmnngTime: int | None = None + disChrgngRmnngTimeV: int | None = None + imcuChrgngEstdElecRng: int | None = None + imcuChrgngEstdElecRngV: int | None = None + imcuDschrgngEstdElecRng: int | None = None + imcuDschrgngEstdElecRngV: int | None = None + imcuVehElecRng: int | None = None + imcuVehElecRngV: int | None = None + onBdChrgrAltrCrntInptCrnt: int | None = None + onBdChrgrAltrCrntInptVol: int | None = None + + @property + def decoded_current(self) -> float | None: + return ( + self.bmsPackCrnt * 0.05 - 1000.0 if self.bmsPackCrnt is not None else None + ) + + @property + def decoded_voltage(self) -> float | None: + return self.bmsPackVol * 0.25 if self.bmsPackVol is not None else None + + @property + def decoded_power(self) -> float | None: + return ( + self.decoded_current * self.decoded_voltage / 1000.0 + if self.decoded_current is not None and self.decoded_voltage is not None + else None + ) + + @property + def charge_target_soc(self) -> TargetBatteryCode | None: raw_target_soc = self.bmsOnBdChrgTrgtSOCDspCmd try: return TargetBatteryCode(raw_target_soc) @@ -259,7 +281,7 @@ def charge_target_soc(self) -> Optional[TargetBatteryCode]: return None @property - def charge_current_limit(self) -> Optional[ChargeCurrentLimitCode]: + def charge_current_limit(self) -> ChargeCurrentLimitCode | None: raw_charge_current_limit = self.bmsAltngChrgCrntDspCmd try: return ChargeCurrentLimitCode(raw_charge_current_limit) @@ -276,9 +298,7 @@ def charging_port_locked(self) -> bool: @property def is_bms_charging(self) -> bool: - if self.bmsChrgSts is not None and self.bmsChrgSts in (1, 3, 10, 12): - return True - return False + return bool(self.bmsChrgSts is not None and self.bmsChrgSts in (1, 3, 10, 12)) @property def bms_charging_status(self) -> BmsChargingStatusCode | None: @@ -294,72 +314,72 @@ def charging_stop_reason(self) -> ChargingStopReason | None: @property def heating_stop_reason(self) -> HeatingStopReason | None: - if self.bmsPTCHeatSpRsn is not None: + if self.bmsPTCHeatResp is not None: return HeatingStopReason.to_code(self.bmsPTCHeatResp) return None @dataclass class RvsChargeStatus: - chargingDuration: Optional[int] = None - chargingElectricityPhase: Optional[int] = None - chargingGunState: int = None - chargingPileID: Optional[str] = None - chargingPileSupplier: Optional[str] = None - chargingType: int = None - endTime: Optional[int] = None - extendedData1: Optional[int] = None - extendedData2: Optional[int] = None - extendedData3: Optional[str] = None - extendedData4: Optional[str] = None - fotaLowestVoltage: Optional[int] = None - fuelRangeElec: int = None - lastChargeEndingPower: Optional[int] = None - mileage: int = None - mileageOfDay: Optional[int] = None - mileageSinceLastCharge: Optional[int] = None - powerUsageOfDay: Optional[int] = None - powerUsageSinceLastCharge: Optional[int] = None - realtimePower: int = None - startTime: Optional[int] = None - staticEnergyConsumption: Optional[int] = None - totalBatteryCapacity: Optional[int] = None - workingCurrent: Optional[int] = None - workingVoltage: Optional[int] = None + chargingDuration: int | None = None + chargingElectricityPhase: int | None = None + chargingGunState: int | None = None + chargingPileID: str | None = None + chargingPileSupplier: str | None = None + chargingType: int | None = None + endTime: int | None = None + extendedData1: int | None = None + extendedData2: int | None = None + extendedData3: str | None = None + extendedData4: str | None = None + fotaLowestVoltage: int | None = None + fuelRangeElec: int | None = None + lastChargeEndingPower: int | None = None + mileage: int | None = None + mileageOfDay: int | None = None + mileageSinceLastCharge: int | None = None + powerUsageOfDay: int | None = None + powerUsageSinceLastCharge: int | None = None + realtimePower: int | None = None + startTime: int | None = None + staticEnergyConsumption: int | None = None + totalBatteryCapacity: int | None = None + workingCurrent: int | None = None + workingVoltage: int | None = None @dataclass class ChrgMgmtDataResp: - chrgMgmtData: ChrgMgmtData = None, - rvsChargeStatus: RvsChargeStatus = None, + chrgMgmtData: ChrgMgmtData | None = None + rvsChargeStatus: RvsChargeStatus | None = None @dataclass class ChargingSettingRequest: - altngChrgCrntReq: int = None - onBdChrgTrgtSOCReq: int = None - tboxV2XSpSOCReq: int = None - vin: str = None + altngChrgCrntReq: int | None = None + onBdChrgTrgtSOCReq: int | None = None + tboxV2XSpSOCReq: int | None = None + vin: str | None = None @dataclass class ChargingSettingResp: - bmsAltngChrgCrntDspCmd: int = None - bmsAltngChrgCrntResp: int = None - bmsChrgTrgtSOCResp: int = None - bmsEstdElecRng: int = None - bmsOnBdChrgTrgtSOCDspCmd: int = None - bmsPackCrnt: int = None - imcuDschrgTrgtSOCDspCmd: int = None - imcuDschrgTrgtSOCResp: int = None - rvcReqSts: Optional[str | int] = None + bmsAltngChrgCrntDspCmd: int | None = None + bmsAltngChrgCrntResp: int | None = None + bmsChrgTrgtSOCResp: int | None = None + bmsEstdElecRng: int | None = None + bmsOnBdChrgTrgtSOCDspCmd: int | None = None + bmsPackCrnt: int | None = None + imcuDschrgTrgtSOCDspCmd: int | None = None + imcuDschrgTrgtSOCResp: int | None = None + rvcReqSts: str | int | None = None @property - def rvc_req_sts_decoded(self) -> Optional[bytes]: - return decode_bytes(input_value=self.rvcReqSts, field_name='rvcReqSts') + def rvc_req_sts_decoded(self) -> bytes | None: + return decode_bytes(input_value=self.rvcReqSts, field_name="rvcReqSts") @property - def charge_target_soc(self) -> Optional[TargetBatteryCode]: + def charge_target_soc(self) -> TargetBatteryCode | None: raw_target_soc = self.bmsOnBdChrgTrgtSOCDspCmd try: return TargetBatteryCode(raw_target_soc) @@ -367,7 +387,7 @@ def charge_target_soc(self) -> Optional[TargetBatteryCode]: return None @property - def charge_current_limit(self) -> Optional[ChargeCurrentLimitCode]: + def charge_current_limit(self) -> ChargeCurrentLimitCode | None: raw_charge_current_limit = self.bmsAltngChrgCrntDspCmd try: return ChargeCurrentLimitCode(raw_charge_current_limit) @@ -375,7 +395,7 @@ def charge_current_limit(self) -> Optional[ChargeCurrentLimitCode]: return None @property - def v2x_target_soc(self) -> Optional[TargetBatteryCode]: + def v2x_target_soc(self) -> TargetBatteryCode | None: raw_target_soc = self.imcuDschrgTrgtSOCDspCmd try: return TargetBatteryCode(raw_target_soc) @@ -385,46 +405,46 @@ def v2x_target_soc(self) -> Optional[TargetBatteryCode]: @dataclass class ScheduledChargingRequest: - rsvanSpHour: int = None - rsvanSpMintue: int = None - rsvanStHour: int = None - rsvanStMintue: int = None - tboxAdpPubChrgSttnReq: int = None - tboxReserCtrlReq: int = None - vin: str = None + rsvanSpHour: int | None = None + rsvanSpMintue: int | None = None + rsvanStHour: int | None = None + rsvanStMintue: int | None = None + tboxAdpPubChrgSttnReq: int | None = None + tboxReserCtrlReq: int | None = None + vin: str | None = None @dataclass class ScheduledChargingResp: - bmsAdpPubChrgSttnDspCmd: int = None - bmsReserChrgCtrlResp: int = None - bmsReserCtrlDspCmd: int = None - bmsReserSpHourDspCmd: int = None - bmsReserSpMintueDspCmd: int = None - bmsReserStHourDspCmd: int = None - bmsReserStMintueDspCmd: int = None - rvcReqSts: Optional[str | int] = None + bmsAdpPubChrgSttnDspCmd: int | None = None + bmsReserChrgCtrlResp: int | None = None + bmsReserCtrlDspCmd: int | None = None + bmsReserSpHourDspCmd: int | None = None + bmsReserSpMintueDspCmd: int | None = None + bmsReserStHourDspCmd: int | None = None + bmsReserStMintueDspCmd: int | None = None + rvcReqSts: str | int | None = None @property - def rvc_req_sts_decoded(self) -> Optional[bytes]: - return decode_bytes(input_value=self.rvcReqSts, field_name='rvcReqSts') + def rvc_req_sts_decoded(self) -> bytes | None: + return decode_bytes(input_value=self.rvcReqSts, field_name="rvcReqSts") @dataclass class ChargingPtcHeatRequest: - ptcHeatReq: int = None - vin: str = None + ptcHeatReq: int | None = None + vin: str | None = None @dataclass class ChrgPtcHeatResp: - ptcHeatReqDspCmd: int = None - ptcHeatResp: int = None - rvcReqSts: Optional[str | int] = None + ptcHeatReqDspCmd: int | None = None + ptcHeatResp: int | None = None + rvcReqSts: str | int | None = None @property - def rvc_req_sts_decoded(self) -> Optional[bytes]: - return decode_bytes(input_value=self.rvcReqSts, field_name='rvcReqSts') + def rvc_req_sts_decoded(self) -> bytes | None: + return decode_bytes(input_value=self.rvcReqSts, field_name="rvcReqSts") @property def heating_stop_reason(self) -> HeatingStopReason | None: @@ -435,76 +455,76 @@ def heating_stop_reason(self) -> HeatingStopReason | None: @dataclass class ChargingControlRequest: - chrgCtrlReq: int = None - tboxEleccLckCtrlReq: int = None - tboxV2XReq: int = None - vin: str = None + chrgCtrlReq: int | None = None + tboxEleccLckCtrlReq: int | None = None + tboxV2XReq: int | None = None + vin: str | None = None @dataclass class ChargingControlResp: - bmsAdpPubChrgSttnDspCmd: int = None - bmsAltngChrgCrntDspCmd: int = None - bmsAltngChrgCrntResp: int = None - bmsChrgCtrlDspCmd: int = None - bmsChrgOtptCrntReq: int = None - bmsChrgOtptCrntReqV: int = None - bmsChrgSpRsn: int = None - bmsChrgSts: int = None - bmsChrgTrgtSOCResp: int = None - bmsDsChrgCtrlDspCmd: int = None - bmsDsChrgCtrlResp: int = None - bmsDsChrgSpRsn: int = None - bmsEstdElecRng: int = None - bmsOnBdChrgTrgtSOCDspCmd: int = None - bmsPTCHeatReqDspCmd: int = None - bmsPTCHeatResp: int = None - bmsPTCHeatSpRsn: int = None - bmsPackCrnt: int = None - bmsPackCrntV: int = None - bmsPackSOCDsp: int = None - bmsPackVol: int = None - bmsReserChrgCtrlResp: int = None - bmsReserCtrlDspCmd: int = None - bmsReserSpHourDspCmd: int = None - bmsReserSpMintueDspCmd: int = None - bmsReserStHourDspCmd: int = None - bmsReserStMintueDspCmd: int = None - ccuEleccLckCtrlDspCmd: int = None - ccuEleccLckCtrlResp: int = None - ccuOffBdChrgrPlugOn: int = None - ccuOnbdChrgrPlugOn: int = None - chrgCtrlDspCmd: int = None - chrgCtrlResp: int = None - chrgngAddedElecRng: int = None - chrgngAddedElecRngV: int = None - chrgngDoorOpenCnd: int = None - chrgngDoorPosSts: int = None - chrgngRmnngTime: int = None - chrgngRmnngTimeV: int = None - chrgngSpdngTime: int = None - chrgngSpdngTimeV: int = None - clstrElecRngToEPT: int = None - disChrgngRmnngTime: int = None - disChrgngRmnngTimeV: int = None - imcuChrgngEstdElecRng: int = None - imcuChrgngEstdElecRngV: int = None - imcuDschrgTrgtSOCDspCmd: int = None - imcuDschrgTrgtSOCResp: int = None - imcuDschrgngEstdElecRng: int = None - imcuDschrgngEstdElecRngV: int = None - imcuVehElecRng: int = None - imcuVehElecRngV: int = None - onBdChrgrAltrCrntInptCrnt: int = None - onBdChrgrAltrCrntInptVol: int = None - rvcReqSts: Optional[str | int] = None - - @property - def rvc_req_sts_decoded(self) -> Optional[bytes]: - return decode_bytes(input_value=self.rvcReqSts, field_name='rvcReqSts') - - @property - def charge_target_soc(self) -> Optional[TargetBatteryCode]: + bmsAdpPubChrgSttnDspCmd: int | None = None + bmsAltngChrgCrntDspCmd: int | None = None + bmsAltngChrgCrntResp: int | None = None + bmsChrgCtrlDspCmd: int | None = None + bmsChrgOtptCrntReq: int | None = None + bmsChrgOtptCrntReqV: int | None = None + bmsChrgSpRsn: int | None = None + bmsChrgSts: int | None = None + bmsChrgTrgtSOCResp: int | None = None + bmsDsChrgCtrlDspCmd: int | None = None + bmsDsChrgCtrlResp: int | None = None + bmsDsChrgSpRsn: int | None = None + bmsEstdElecRng: int | None = None + bmsOnBdChrgTrgtSOCDspCmd: int | None = None + bmsPTCHeatReqDspCmd: int | None = None + bmsPTCHeatResp: int | None = None + bmsPTCHeatSpRsn: int | None = None + bmsPackCrnt: int | None = None + bmsPackCrntV: int | None = None + bmsPackSOCDsp: int | None = None + bmsPackVol: int | None = None + bmsReserChrgCtrlResp: int | None = None + bmsReserCtrlDspCmd: int | None = None + bmsReserSpHourDspCmd: int | None = None + bmsReserSpMintueDspCmd: int | None = None + bmsReserStHourDspCmd: int | None = None + bmsReserStMintueDspCmd: int | None = None + ccuEleccLckCtrlDspCmd: int | None = None + ccuEleccLckCtrlResp: int | None = None + ccuOffBdChrgrPlugOn: int | None = None + ccuOnbdChrgrPlugOn: int | None = None + chrgCtrlDspCmd: int | None = None + chrgCtrlResp: int | None = None + chrgngAddedElecRng: int | None = None + chrgngAddedElecRngV: int | None = None + chrgngDoorOpenCnd: int | None = None + chrgngDoorPosSts: int | None = None + chrgngRmnngTime: int | None = None + chrgngRmnngTimeV: int | None = None + chrgngSpdngTime: int | None = None + chrgngSpdngTimeV: int | None = None + clstrElecRngToEPT: int | None = None + disChrgngRmnngTime: int | None = None + disChrgngRmnngTimeV: int | None = None + imcuChrgngEstdElecRng: int | None = None + imcuChrgngEstdElecRngV: int | None = None + imcuDschrgTrgtSOCDspCmd: int | None = None + imcuDschrgTrgtSOCResp: int | None = None + imcuDschrgngEstdElecRng: int | None = None + imcuDschrgngEstdElecRngV: int | None = None + imcuVehElecRng: int | None = None + imcuVehElecRngV: int | None = None + onBdChrgrAltrCrntInptCrnt: int | None = None + onBdChrgrAltrCrntInptVol: int | None = None + rvcReqSts: str | int | None = None + + @property + def rvc_req_sts_decoded(self) -> bytes | None: + return decode_bytes(input_value=self.rvcReqSts, field_name="rvcReqSts") + + @property + def charge_target_soc(self) -> TargetBatteryCode | None: raw_target_soc = self.bmsOnBdChrgTrgtSOCDspCmd try: return TargetBatteryCode(raw_target_soc) @@ -512,7 +532,7 @@ def charge_target_soc(self) -> Optional[TargetBatteryCode]: return None @property - def charge_current_limit(self) -> Optional[ChargeCurrentLimitCode]: + def charge_current_limit(self) -> ChargeCurrentLimitCode | None: raw_charge_current_limit = self.bmsAltngChrgCrntDspCmd try: return ChargeCurrentLimitCode(raw_charge_current_limit) @@ -520,7 +540,7 @@ def charge_current_limit(self) -> Optional[ChargeCurrentLimitCode]: return None @property - def v2x_target_soc(self) -> Optional[TargetBatteryCode]: + def v2x_target_soc(self) -> TargetBatteryCode | None: raw_target_soc = self.imcuDschrgTrgtSOCDspCmd try: return TargetBatteryCode(raw_target_soc) @@ -537,9 +557,7 @@ def charging_port_locked(self) -> bool: @property def is_bms_charging(self) -> bool: - if self.bmsChrgSts is not None and self.bmsChrgSts in (1, 3, 10, 12): - return True - return False + return bool(self.bmsChrgSts is not None and self.bmsChrgSts in (1, 3, 10, 12)) @property def bms_charging_status(self) -> BmsChargingStatusCode | None: @@ -562,22 +580,22 @@ def heating_stop_reason(self) -> HeatingStopReason | None: @dataclass class ScheduledBatteryHeatingRequest: - startTime: int = None - status: int = None - vin: str = None + startTime: int | None = None + status: int | None = None + vin: str | None = None @dataclass class ScheduledBatteryHeatingResp: - startTime: int = None - status: int = None + startTime: int | None = None + status: int | None = None @property def is_enabled(self) -> bool: return self.status == 1 @property - def decoded_start_time(self) -> Optional[datetime.time]: + def decoded_start_time(self) -> datetime.time | None: if self.startTime is None: return None return datetime.datetime.fromtimestamp(self.startTime / 1000).time() diff --git a/src/saic_ismart_client_ng/crypto_utils.py b/src/saic_ismart_client_ng/crypto_utils.py index 984cf7c..3ca91e2 100644 --- a/src/saic_ismart_client_ng/crypto_utils.py +++ b/src/saic_ismart_client_ng/crypto_utils.py @@ -1,6 +1,8 @@ +from __future__ import annotations + +from binascii import hexlify, unhexlify import hashlib import logging -from binascii import unhexlify, hexlify from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad @@ -8,12 +10,12 @@ logger = logging.getLogger(__name__) -def md5_hex_digest(content, do_padding): +def md5_hex_digest(content: str, do_padding: bool) -> str: if do_padding: content = content + "00" try: - message_digest = hashlib.md5() + message_digest = hashlib.md5() # noqa: S324 message_digest.update(content.encode()) hash_result = message_digest.digest() @@ -24,61 +26,69 @@ def md5_hex_digest(content, do_padding): if v1 < 16: hex_string += "0" - hex_string += format(v1, 'x') + hex_string += format(v1, "x") return hex_string except Exception as e: - logger.error("Could not compute md5 hex digest for input string=%s", content, exc_info=e) - return None + logger.exception( + "Could not compute md5 hex digest for input string=%s", content, exc_info=e + ) + raise RuntimeError(e) from e -def sha1_hex_digest(content): +def sha1_hex_digest(content: str) -> str: try: - message_digest = hashlib.sha1() + message_digest = hashlib.sha1() # noqa: S324 message_digest.update(content.encode()) return message_digest.hexdigest() except Exception as e: - logger.error("Could not compute sha1 hex digest for input string=%s", content, exc_info=e) - raise RuntimeError(e) + logger.exception( + "Could not compute sha1 hex digest for input string=%s", content, exc_info=e + ) + raise RuntimeError(e) from e -def sha256_hex_digest(content): +def sha256_hex_digest(content: str) -> str: try: message_digest = hashlib.sha256() message_digest.update(content.encode()) return message_digest.hexdigest() except Exception as e: - logger.error("Could not compute sha256 hex digest for input string=%s", content, exc_info=e) - raise RuntimeError(e) - - -def encrypt_aes_cbc_pkcs5_padding(content, key, iv): - if content and key and iv: - try: - key_bytes = unhexlify(key) - iv_bytes = unhexlify(iv) - cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) - padded_content = pad(content.encode('utf-8'), AES.block_size) - encrypted_content = cipher.encrypt(padded_content) - return hexlify(encrypted_content).decode('utf-8') - except Exception as e: - logger.error("Could not encrypt content=%s", content, exc_info=e) - raise RuntimeError(e) - return None - - -def decrypt_aes_cbc_pkcs5_padding(cypher_text, key, iv): - if cypher_text and key and iv: - try: - key_bytes = unhexlify(key) - iv_bytes = unhexlify(iv) - cypher_text_bytes = unhexlify(cypher_text) - - cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) - decrypted_text = unpad(cipher.decrypt(cypher_text_bytes), AES.block_size) - - return decrypted_text.decode('utf-8') - except Exception as exception0: - raise RuntimeError(exception0) - - return None + logger.exception( + "Could not compute sha256 hex digest for input string=%s", + content, + exc_info=e, + ) + raise RuntimeError(e) from e + + +def encrypt_aes_cbc_pkcs5_padding(content: str | bytes, key: str, iv: str) -> str: + try: + key_bytes = unhexlify(key) + iv_bytes = unhexlify(iv) + cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) + if isinstance(content, bytes): + content_as_bytes = content + else: + content_as_bytes = content.encode("utf-8") + padded_content = pad(content_as_bytes, AES.block_size) + encrypted_content = cipher.encrypt(padded_content) + return hexlify(encrypted_content).decode("utf-8") + except Exception as e: + logger.exception("Could not encrypt content=%s", content, exc_info=e) + raise RuntimeError(e) from e + + +def decrypt_aes_cbc_pkcs5_padding(cypher_text: str, key: str, iv: str) -> str: + try: + key_bytes = unhexlify(key) + iv_bytes = unhexlify(iv) + cypher_text_bytes = unhexlify(cypher_text) + + cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) + decrypted_text = unpad(cipher.decrypt(cypher_text_bytes), AES.block_size) + + return decrypted_text.decode("utf-8") + except Exception as e: + logger.exception("Could not decrypt content=%s", cypher_text, exc_info=e) + raise RuntimeError(e) from e diff --git a/src/saic_ismart_client_ng/exceptions.py b/src/saic_ismart_client_ng/exceptions.py index 21293cf..93e266c 100644 --- a/src/saic_ismart_client_ng/exceptions.py +++ b/src/saic_ismart_client_ng/exceptions.py @@ -1,11 +1,14 @@ +from __future__ import annotations + + class SaicApiException(Exception): - def __init__(self, msg: str, return_code: int = None): + def __init__(self, msg: str, return_code: int | None = None) -> None: if return_code is not None: - self.message = f'return code: {return_code}, message: {msg}' + self.message = f"return code: {return_code}, message: {msg}" else: self.message = msg - def __str__(self): + def __str__(self) -> str: return self.message @@ -14,7 +17,9 @@ class SaicLogoutException(SaicApiException): class SaicApiRetryException(SaicApiException): - def __init__(self, msg: str, *, event_id: str, return_code: int = None): + def __init__( + self, msg: str, *, event_id: str, return_code: int | None = None + ) -> None: super().__init__(msg, return_code) self.__event_id = event_id @@ -22,5 +27,5 @@ def __init__(self, msg: str, *, event_id: str, return_code: int = None): def event_id(self) -> str: return self.__event_id - def __str__(self): - return f'{self.message}, event_id: {self.event_id}' + def __str__(self) -> str: + return f"{self.message}, event_id: {self.event_id}" diff --git a/src/saic_ismart_client_ng/listener.py b/src/saic_ismart_client_ng/listener.py index 93756d1..9927f32 100644 --- a/src/saic_ismart_client_ng/listener.py +++ b/src/saic_ismart_client_ng/listener.py @@ -1,10 +1,13 @@ -from abc import ABC -from typing import Optional +from __future__ import annotations -class SaicApiListener(ABC): - async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): +class SaicApiListener: + async def on_request( + self, path: str, body: str | None = None, headers: dict[str, str] | None = None + ) -> None: pass - async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def on_response( + self, path: str, body: str | None = None, headers: dict[str, str] | None = None + ) -> None: pass diff --git a/src/saic_ismart_client_ng/model.py b/src/saic_ismart_client_ng/model.py index 5b81e4d..b3072ce 100644 --- a/src/saic_ismart_client_ng/model.py +++ b/src/saic_ismart_client_ng/model.py @@ -1,15 +1,19 @@ +from __future__ import annotations + + class SaicApiConfiguration: + # pylint: disable=too-many-positional-arguments def __init__( - self, - username: str, - password: str, - username_is_email: bool = True, - phone_country_code: str = None, - base_uri: str = "https://gateway-mg-eu.soimt.com/api.app/v1/", - tenant_id: str = "459771", - region: str = "eu", - sms_delivery_delay: float = 3.0, - ): + self, + username: str, + password: str, + username_is_email: bool = True, + phone_country_code: str | None = None, + base_uri: str = "https://gateway-mg-eu.soimt.com/api.app/v1/", + tenant_id: str = "459771", + region: str = "eu", + sms_delivery_delay: float = 3.0, + ) -> None: self.__username = username self.__password = password self.__username_is_email = username_is_email @@ -20,33 +24,33 @@ def __init__( self.__sms_delivery_delay = sms_delivery_delay @property - def username(self): + def username(self) -> str: return self.__username @property - def password(self): + def password(self) -> str: return self.__password @property - def username_is_email(self): + def username_is_email(self) -> bool: return self.__username_is_email @property - def phone_country_code(self): + def phone_country_code(self) -> str | None: return self.__phone_country_code @property - def base_uri(self): + def base_uri(self) -> str: return self.__base_uri @property - def tenant_id(self): + def tenant_id(self) -> str: return self.__tenant_id @property - def region(self): + def region(self) -> str: return self.__region @property - def sms_delivery_delay(self): + def sms_delivery_delay(self) -> float: return self.__sms_delivery_delay diff --git a/src/saic_ismart_client_ng/net/__init__.py b/src/saic_ismart_client_ng/net/__init__.py index b28b04f..8b13789 100644 --- a/src/saic_ismart_client_ng/net/__init__.py +++ b/src/saic_ismart_client_ng/net/__init__.py @@ -1,3 +1 @@ - - diff --git a/src/saic_ismart_client_ng/net/client/__init__.py b/src/saic_ismart_client_ng/net/client/__init__.py index b3e92a5..c0182e3 100644 --- a/src/saic_ismart_client_ng/net/client/__init__.py +++ b/src/saic_ismart_client_ng/net/client/__init__.py @@ -1,37 +1,41 @@ -import logging +from __future__ import annotations + from datetime import datetime +import logging +from typing import TYPE_CHECKING import httpx from httpx import Request, Response -from saic_ismart_client_ng.listener import SaicApiListener -from saic_ismart_client_ng.model import SaicApiConfiguration -from saic_ismart_client_ng.net.httpx import decrypt_httpx_response, encrypt_httpx_request +from saic_ismart_client_ng.net.httpx import ( + decrypt_httpx_response, + encrypt_httpx_request, +) + +if TYPE_CHECKING: + from saic_ismart_client_ng.listener import SaicApiListener + from saic_ismart_client_ng.model import SaicApiConfiguration class SaicApiClient: def __init__( - self, - configuration: SaicApiConfiguration, - listener: SaicApiListener = None, - logger: logging.Logger = logging.getLogger(__name__) - ): + self, + configuration: SaicApiConfiguration, + listener: SaicApiListener | None = None, + ) -> None: self.__configuration = configuration self.__listener = listener - self.__logger = logger - self.__user_token: str = '' - self.__class_name: str = '' + self.__logger = logging.getLogger(__name__) + self.__user_token: str = "" + self.__class_name: str = "" self.__client = httpx.AsyncClient( event_hooks={ "request": [self.__invoke_request_listener, self.__encrypt_request], - "response": [decrypt_httpx_response, self.__invoke_response_listener] + "response": [decrypt_httpx_response, self.__invoke_response_listener], } ) - async def send( - self, - request: Request - ) -> Response: + async def send(self, request: Request) -> Response: return await self.__client.send(request) @property @@ -39,55 +43,56 @@ def user_token(self) -> str: return self.__user_token @user_token.setter - def user_token(self, new_token: str): + def user_token(self, new_token: str) -> None: self.__user_token = new_token - async def __invoke_request_listener(self, request: httpx.Request): + async def __invoke_request_listener(self, request: httpx.Request) -> None: if not self.__listener: return try: body = None - if request.content: + if content := request.content: try: - - body = request.content.decode("utf-8") - except Exception as e: - self.__logger.warning(f"Error decoding request content: {e}", exc_info=e) + body = content.decode("utf-8") + except Exception: + self.__logger.exception( + "Error decoding request content: %s", content + ) await self.__listener.on_request( path=str(request.url).replace(self.__configuration.base_uri, "/"), body=body, headers=dict(request.headers), ) - except Exception as e: - self.__logger.warning(f"Error invoking request listener: {e}", exc_info=e) + except Exception: + self.__logger.exception("Error invoking request listener") - async def __invoke_response_listener(self, response: httpx.Response): + async def __invoke_response_listener(self, response: httpx.Response) -> None: if not self.__listener: return try: body = await response.aread() + decoded_body = None if body: try: - body = body.decode("utf-8") - except Exception as e: - self.__logger.warning(f"Error decoding request content: {e}", exc_info=e) + decoded_body = body.decode("utf-8") + except Exception: + self.__logger.exception("Error decoding request content: %s", body) await self.__listener.on_response( path=str(response.url).replace(self.__configuration.base_uri, "/"), - body=body, + body=decoded_body, headers=dict(response.headers), ) - except Exception as e: - self.__logger.warning(f"Error invoking request listener: {e}", exc_info=e) + except Exception: + self.__logger.exception("Error invoking request listener") - async def __encrypt_request(self, modified_request: httpx.Request): - return await encrypt_httpx_request( + async def __encrypt_request(self, modified_request: httpx.Request) -> None: + await encrypt_httpx_request( modified_request=modified_request, request_timestamp=datetime.now(), base_uri=self.__configuration.base_uri, region=self.__configuration.region, tenant_id=self.__configuration.tenant_id, user_token=self.user_token, - class_name=self.__class_name ) diff --git a/src/saic_ismart_client_ng/net/crypto.py b/src/saic_ismart_client_ng/net/crypto.py index 54344d1..1aa9790 100644 --- a/src/saic_ismart_client_ng/net/crypto.py +++ b/src/saic_ismart_client_ng/net/crypto.py @@ -1,92 +1,115 @@ +from __future__ import annotations + import hashlib import hmac import logging -from datetime import datetime +from typing import TYPE_CHECKING -from saic_ismart_client_ng.crypto_utils import md5_hex_digest, encrypt_aes_cbc_pkcs5_padding, \ - decrypt_aes_cbc_pkcs5_padding +from saic_ismart_client_ng.crypto_utils import ( + decrypt_aes_cbc_pkcs5_padding, + encrypt_aes_cbc_pkcs5_padding, + md5_hex_digest, +) from saic_ismart_client_ng.net.utils import normalize_content_type +if TYPE_CHECKING: + from collections.abc import MutableMapping + from datetime import datetime + + Headers = MutableMapping[str, str] + logger = logging.getLogger(__name__) def get_app_verification_string( - clazz_simple_name, - request_path, - current_ts, - tenant_id, - content_type, - request_content, - user_token -): - api_name = request_path if (len(request_path) > 0 or "?" not in request_path) else request_path.split("?")[0] + *, + request_path: str, + current_ts: str, + tenant_id: str, + content_type: str, + request_content: str, + user_token: str, +) -> str: + # api_name = ( + # request_path + # if (len(request_path) == 0 or "?" not in request_path) + # else request_path.split("?")[0] + # ) origin_key_part_one = request_path + tenant_id + user_token + "app" encrypt_key_part_one = md5_hex_digest(origin_key_part_one, False) origin_key_part_two = current_ts + "1" + content_type encrypt_key = md5_hex_digest(encrypt_key_part_one + origin_key_part_two, False) encrypt_iv = md5_hex_digest(current_ts, False) - encrypt_req = encrypt_aes_cbc_pkcs5_padding(request_content, encrypt_key, - encrypt_iv) if len(request_content) > 0 else "" - hmac_sha256_value = request_path + tenant_id + user_token + "app" + current_ts + "1" + content_type + encrypt_req + encrypt_req = ( + encrypt_aes_cbc_pkcs5_padding(request_content, encrypt_key, encrypt_iv) + if len(request_content) > 0 + else "" + ) + hmac_sha256_value = ( + request_path + + tenant_id + + user_token + + "app" + + current_ts + + "1" + + content_type + + encrypt_req + ) hmac_sha256_key = md5_hex_digest(encrypt_key + current_ts, False) - if len(hmac_sha256_key) > 0 and len(hmac_sha256_value) > 0: - app_verification_string = hmac.new( + if len(hmac_sha256_key) > 0: + return hmac.new( hmac_sha256_key.encode(), msg=hmac_sha256_value.encode(), - digestmod=hashlib.sha256 + digestmod=hashlib.sha256, ).hexdigest() - logger.debug( - f"{clazz_simple_name} headerInterceptor apiName--->>> {api_name} \n encryptURI --->>> {request_path} \n origin_key_part_one --->>> {origin_key_part_one} \n encrypt_key_part_one --->>> {encrypt_key_part_one} \n origin_key_part_two --->>> {origin_key_part_two} \n 完整的加密Key encrypt_key --->>> {encrypt_key} \n 加密IV encrypt_iv --->>> {encrypt_iv} \n 原始params paramsStr --->>> {request_content} \n 加密params encrypt_req --->>> {encrypt_req} \n hmac_sha256_value --->>> {hmac_sha256_value} \n origin hmacKey --->>> {encrypt_key + current_ts} \n hmacSha256Key --->>> {hmac_sha256_key} \n APP-VERIFICATION-STRING --->>> {app_verification_string}" - ) - return app_verification_string - - logger.debug( - f"{clazz_simple_name} headerInterceptor apiName--->>> {api_name} \n encryptURI --->>> {request_path} \n origin_key_part_one --->>> {origin_key_part_one} \n encrypt_key_part_one --->>> {encrypt_key_part_one} \n origin_key_part_two --->>> {origin_key_part_two} \n 完整的加密Key encrypt_key --->>> {encrypt_key} \n 加密IV encrypt_iv --->>> {encrypt_iv} \n 原始params paramsStr --->>> {request_content} \n 加密params encrypt_req --->>> {encrypt_req} \n hmac_sha256_value --->>> {hmac_sha256_value} \n origin hmacKey --->>> {encrypt_key + current_ts} \n hmacSha256Key --->>> {hmac_sha256_key}") return "" def encrypt_request( - *, - original_request_url: str, - original_request_headers: dict, - original_request_content: str, - request_timestamp: datetime, - base_uri: str, - region: str, - tenant_id: str, - user_token: str = "", - class_name: str = "", -) -> (str, dict): - if user_token is None: - user_token = "" - if class_name is None: - class_name = "" - original_content_type = original_request_headers.get("Content-Type") # 'application/x-www-form-urlencoded' + *, + original_request_url: str, + original_request_headers: Headers, + original_request_content: str, + request_timestamp: datetime, + base_uri: str, + region: str, + tenant_id: str, + user_token: str = "", +) -> tuple[str | bytes, Headers]: + original_content_type = original_request_headers.get( + "Content-Type" + ) # 'application/x-www-form-urlencoded' if not original_content_type: modified_content_type = "application/json" else: - modified_content_type = original_content_type # 'application/x-www-form-urlencoded' + modified_content_type = ( + original_content_type # 'application/x-www-form-urlencoded' + ) request_content = "" current_ts = str(int(request_timestamp.timestamp() * 1000)) request_path = str(original_request_url).replace(base_uri, "/") request_body = original_request_content - new_content = original_request_content - if request_body and "multipart" not in original_content_type: + new_content: str | bytes = original_request_content + if request_body and ( + not original_content_type or "multipart" not in original_content_type + ): modified_content_type = normalize_content_type(original_content_type) request_content = request_body.strip() if request_content: key_hex = md5_hex_digest( - md5_hex_digest( - request_path + tenant_id + user_token + "app", - False - ) + current_ts + "1" + modified_content_type, - False + md5_hex_digest(request_path + tenant_id + user_token + "app", False) + + current_ts + + "1" + + modified_content_type, + False, ) iv_hex = md5_hex_digest(current_ts, False) if key_hex and iv_hex: - new_content = encrypt_aes_cbc_pkcs5_padding(request_content, key_hex, iv_hex).encode('utf-8') + new_content = encrypt_aes_cbc_pkcs5_padding( + request_content, key_hex, iv_hex + ).encode("utf-8") original_request_headers["User-Agent"] = "okhttp/3.14.9" original_request_headers["Content-Type"] = f"{modified_content_type};charset=utf-8" @@ -103,13 +126,12 @@ def encrypt_request( if user_token: original_request_headers["blade-auth"] = user_token app_verification_string = get_app_verification_string( - class_name, - request_path, - current_ts, - tenant_id, - modified_content_type, - request_content, - user_token + request_path=request_path, + current_ts=current_ts, + tenant_id=tenant_id, + content_type=modified_content_type, + request_content=request_content, + user_token=user_token, ) original_request_headers["APP-VERIFICATION-STRING"] = app_verification_string original_request_headers["ORIGINAL-CONTENT-TYPE"] = modified_content_type @@ -117,27 +139,27 @@ def encrypt_request( def decrypt_request( - *, - original_request_url: str, - original_request_headers: dict, - original_request_content: str, - base_uri: str, + *, + original_request_url: str, + original_request_headers: Headers, + original_request_content: str, + base_uri: str, ) -> bytes: - charset = 'utf-8' + charset = "utf-8" req_content = original_request_content.strip() if req_content: app_send_date = original_request_headers.get("APP-SEND-DATE") original_content_type = original_request_headers.get("ORIGINAL-CONTENT-TYPE") if app_send_date and original_content_type: - tenant_id = original_request_headers['tenant-id'] - user_token = original_request_headers.get('blade-auth', '') + tenant_id = original_request_headers["tenant-id"] + user_token = original_request_headers.get("blade-auth", "") request_path = original_request_url.replace(base_uri, "/") key = md5_hex_digest( - md5_hex_digest( - request_path + tenant_id + user_token + "app", - False - ) + app_send_date + "1" + original_content_type, - False + md5_hex_digest(request_path + tenant_id + user_token + "app", False) + + app_send_date + + "1" + + original_content_type, + False, ) iv = md5_hex_digest(app_send_date, False) decrypted = decrypt_aes_cbc_pkcs5_padding(req_content, key, iv) @@ -147,67 +169,68 @@ def decrypt_request( def encrypt_response( - *, - original_request_url: str, - original_response_headers: dict, - original_response_content: str, - response_timestamp_ms: int, - base_uri: str, - tenant_id: str, - user_token: str = '', -): + *, + original_request_url: str, + original_response_headers: Headers, + original_response_content: str, + response_timestamp_ms: int, + base_uri: str, + tenant_id: str, + user_token: str = "", +) -> tuple[str | bytes, Headers]: request_content = "" request_path = str(original_request_url).replace(base_uri, "/") - original_content_type = original_response_headers.get("Content-Type") # 'application/x-www-form-urlencoded' - if not original_content_type: - modified_content_type = "application/json" - else: - modified_content_type = original_content_type # 'application/x-www-form-urlencoded' + original_content_type = original_response_headers.get( + "Content-Type", "application/json" + ) # 'application/x-www-form-urlencoded' + modified_content_type = original_content_type # 'application/x-www-form-urlencoded' current_ts = str(response_timestamp_ms) request_body = original_response_content - new_content = original_response_content + new_content: str | bytes = original_response_content if request_body and "multipart" not in original_content_type: modified_content_type = normalize_content_type(original_content_type) request_content = request_body.strip() if request_content: - key_hex = md5_hex_digest( - current_ts + "1" + modified_content_type, - False - ) + key_hex = md5_hex_digest(current_ts + "1" + modified_content_type, False) iv_hex = md5_hex_digest(current_ts, False) if key_hex and iv_hex: - new_content = encrypt_aes_cbc_pkcs5_padding(request_content, key_hex, iv_hex).encode('utf-8') + new_content = encrypt_aes_cbc_pkcs5_padding( + request_content, key_hex, iv_hex + ).encode("utf-8") original_response_headers["Content-Type"] = f"{modified_content_type};charset=utf-8" original_response_headers["APP-CONTENT-ENCRYPTED"] = "1" original_response_headers["APP-SEND-DATE"] = current_ts original_response_headers["ORIGINAL-CONTENT-TYPE"] = modified_content_type app_verification_string = get_app_verification_string( - '', - request_path, - current_ts, - tenant_id, - modified_content_type, - request_content, - user_token + request_path=request_path, + current_ts=current_ts, + tenant_id=tenant_id, + content_type=modified_content_type, + request_content=request_content, + user_token=user_token, ) original_response_headers["APP-VERIFICATION-STRING"] = app_verification_string return new_content, original_response_headers def decrypt_response( - *, - original_response_content: str, - original_response_headers: dict, - original_response_charset: str, -) -> (bytes, dict): + *, + original_response_content: str, + original_response_headers: Headers, + original_response_charset: str, +) -> tuple[bytes, Headers]: resp_content = original_response_content.strip() if resp_content: app_send_date = original_response_headers.get("APP-SEND-DATE") original_content_type = original_response_headers.get("ORIGINAL-CONTENT-TYPE") if app_send_date and original_content_type: original_response_key = app_send_date + "1" + original_content_type - key = md5_hex_digest(original_response_key, False) if len(original_response_key) > 0 else "" + key = ( + md5_hex_digest(original_response_key, False) + if len(original_response_key) > 0 + else "" + ) iv = md5_hex_digest(app_send_date, False) decrypted = decrypt_aes_cbc_pkcs5_padding(resp_content, key, iv) if decrypted: diff --git a/src/saic_ismart_client_ng/net/httpx/__init__.py b/src/saic_ismart_client_ng/net/httpx/__init__.py index 3e7e8be..a035829 100644 --- a/src/saic_ismart_client_ng/net/httpx/__init__.py +++ b/src/saic_ismart_client_ng/net/httpx/__init__.py @@ -1,23 +1,31 @@ -from datetime import datetime -from typing import Union +from __future__ import annotations + +from typing import TYPE_CHECKING -import httpx -from httpx import Request, Response from httpx._content import encode_request -from saic_ismart_client_ng.net.crypto import encrypt_request, decrypt_request, decrypt_response +from saic_ismart_client_ng.net.crypto import ( + decrypt_request, + decrypt_response, + encrypt_request, +) + +if TYPE_CHECKING: + from datetime import datetime + + import httpx + from httpx import Request, Response async def encrypt_httpx_request( - *, - modified_request: Request, - request_timestamp: datetime, - base_uri: str, - region: str, - tenant_id: str, - user_token: str = "", - class_name: str = "", -): + *, + modified_request: Request, + request_timestamp: datetime, + base_uri: str, + region: str, + tenant_id: str, + user_token: str = "", +) -> None: new_content, new_headers = encrypt_request( original_request_url=str(modified_request.url), original_request_headers=modified_request.headers, @@ -27,42 +35,47 @@ async def encrypt_httpx_request( region=region, tenant_id=tenant_id, user_token=user_token, - class_name=class_name ) update_httpx_request_with_content(modified_request, new_content) modified_request.headers.update(new_headers) -async def decrypt_httpx_request(req: Request, base_uri: str): - charset = 'utf-8' +async def decrypt_httpx_request(req: Request, base_uri: str) -> bytes | None: + charset = "utf-8" req_content = (await req.aread()).decode(charset).strip() if req_content: return decrypt_request( original_request_url=str(req.url), original_request_headers=req.headers, original_request_content=req_content, - base_uri=base_uri + base_uri=base_uri, ) - return req_content + return None -async def decrypt_httpx_response(resp: Response): +async def decrypt_httpx_response(resp: Response) -> Response: if resp.is_success: - charset = resp.encoding + charset = resp.encoding or "utf-8" resp_content = (await resp.aread()).decode(charset).strip() if resp_content: new_resp_content, new_resp_headers = decrypt_response( original_response_content=resp_content, original_response_headers=resp.headers, - original_response_charset=charset + original_response_charset=charset, ) update_httpx_request_with_content(resp, new_resp_content) resp.headers.update(new_resp_headers) return resp -def update_httpx_request_with_content(modified_request: Union[httpx.Request, httpx.Response], new_content: bytes): - recomputed_headers, recomputed_stream = encode_request(content=new_content) +def update_httpx_request_with_content( + modified_request: httpx.Request | httpx.Response, new_content: str | bytes +) -> None: + if isinstance(new_content, bytes): + content_as_bytes = new_content + else: + content_as_bytes = new_content.encode("utf-8") + recomputed_headers, recomputed_stream = encode_request(content=content_as_bytes) modified_request.stream = recomputed_stream - modified_request._content = new_content + modified_request._content = content_as_bytes # pylint: disable=protected-access #noqa: SLF001 modified_request.headers.update(recomputed_headers) diff --git a/src/saic_ismart_client_ng/net/utils.py b/src/saic_ismart_client_ng/net/utils.py index ba20a63..712a233 100644 --- a/src/saic_ismart_client_ng/net/utils.py +++ b/src/saic_ismart_client_ng/net/utils.py @@ -1,7 +1,11 @@ -def normalize_content_type(original_content_type: str): - if 'multipart' in original_content_type: - return 'multipart/form-data' - elif 'x-www-form-urlencoded' in original_content_type: - return 'application/x-www-form-urlencoded' - else: - return 'application/json' +from __future__ import annotations + + +def normalize_content_type(original_content_type: str | None) -> str: + normalized = "application/json" + if original_content_type: + if "multipart" in original_content_type: + normalized = "multipart/form-data" + elif "x-www-form-urlencoded" in original_content_type: + normalized = "application/x-www-form-urlencoded" + return normalized diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/security_test.py b/tests/security_test.py index 8542cba..f2c614c 100644 --- a/tests/security_test.py +++ b/tests/security_test.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import json import unittest @@ -6,76 +8,94 @@ import pytest from saic_ismart_client_ng.net.crypto import get_app_verification_string -from saic_ismart_client_ng.net.httpx import encrypt_httpx_request, decrypt_httpx_request +from saic_ismart_client_ng.net.httpx import decrypt_httpx_request, encrypt_httpx_request -def test_get_app_verification_string_valid(): - clazz_simple_name = 'SampleClass' - request_path = '/api/v1/data' - current_ts = '20230514123000' - tenant_id = '1234' - content_type = 'application/json' +def test_get_app_verification_string_valid() -> None: + request_path = "/api/v1/data" + current_ts = "20230514123000" + tenant_id = "1234" + content_type = "application/json" request_content = '{"key": "value"}' - user_token = 'dummy_token' - - result = get_app_verification_string(clazz_simple_name, request_path, current_ts, tenant_id, - content_type, request_content, user_token) + user_token = "dummy_token" # noqa: S105 + + result = get_app_verification_string( + request_path=request_path, + current_ts=current_ts, + tenant_id=tenant_id, + content_type=content_type, + request_content=request_content, + user_token=user_token, + ) - assert 'afd4eaf98af2d964f8ea840fc144ee7bae95dbeeeb251d5e3a01371442f92eeb' == result + assert result == "afd4eaf98af2d964f8ea840fc144ee7bae95dbeeeb251d5e3a01371442f92eeb" @pytest.mark.asyncio -async def test_a_request_should_encrypt_properly(): +async def test_a_request_should_encrypt_properly() -> None: ts = datetime.datetime.now() - expected_json = {'change': 'me'} - base_uri = 'http://fake.server/' + expected_json = {"change": "me"} + base_uri = "http://fake.server/" original_request = httpx.Request( - url=f'{base_uri}with/path', - method='GET', - params={'vin': 'zevin'}, - headers={'Content-Type': 'application/json'}, + url=f"{base_uri}with/path", + method="GET", + params={"vin": "zevin"}, + headers={"Content-Type": "application/json"}, json=expected_json, ) - original_request_content = original_request.content.decode('utf-8').strip() - region = 'EU' - tenant_id = '2559' + original_request_content = original_request.content.decode("utf-8").strip() + region = "EU" + tenant_id = "2559" computed_verification_string = get_app_verification_string( - "", - "/with/path?vin=zevin", - str(int(ts.timestamp() * 1000)), - tenant_id, - 'application/json', - original_request_content, '' + request_path="/with/path?vin=zevin", + current_ts=str(int(ts.timestamp() * 1000)), + tenant_id=tenant_id, + content_type="application/json", + request_content=original_request_content, + user_token="", ) - await encrypt_httpx_request(modified_request=original_request, request_timestamp=ts, base_uri=base_uri, - region=region, tenant_id=tenant_id) + await encrypt_httpx_request( + modified_request=original_request, + request_timestamp=ts, + base_uri=base_uri, + region=region, + tenant_id=tenant_id, + ) assert original_request is not None - assert region == original_request.headers['REGION'] - assert tenant_id == original_request.headers['tenant-id'] - assert 'app' == original_request.headers['User-Type'] - assert str(int(ts.timestamp() * 1000)) == original_request.headers['APP-SEND-DATE'] - assert '1' == original_request.headers['APP-CONTENT-ENCRYPTED'] - assert computed_verification_string == original_request.headers['APP-VERIFICATION-STRING'] + assert region == original_request.headers["REGION"] + assert tenant_id == original_request.headers["tenant-id"] + assert original_request.headers["User-Type"] == "app" + assert str(int(ts.timestamp() * 1000)) == original_request.headers["APP-SEND-DATE"] + assert original_request.headers["APP-CONTENT-ENCRYPTED"] == "1" + assert ( + computed_verification_string + == original_request.headers["APP-VERIFICATION-STRING"] + ) @pytest.mark.asyncio -async def test_a_request_should_decrypt_properly(): +async def test_a_request_should_decrypt_properly() -> None: ts = datetime.datetime.now() - expected_json = {'change': 'me'} - base_uri = 'http://fake.server/' + expected_json = {"change": "me"} + base_uri = "http://fake.server/" original_request = httpx.Request( - url=f'{base_uri}with/path', - method='GET', - params={'vin': 'zevin'}, - headers={'Content-Type': 'application/json'}, + url=f"{base_uri}with/path", + method="GET", + params={"vin": "zevin"}, + headers={"Content-Type": "application/json"}, json=expected_json, ) - region = 'EU' - tenant_id = '2559' - - await encrypt_httpx_request(modified_request=original_request, request_timestamp=ts, base_uri=base_uri, - region=region, tenant_id=tenant_id) + region = "EU" + tenant_id = "2559" + + await encrypt_httpx_request( + modified_request=original_request, + request_timestamp=ts, + base_uri=base_uri, + region=region, + tenant_id=tenant_id, + ) decrypted = await decrypt_httpx_request(original_request, base_uri=base_uri) assert decrypted is not None @@ -83,33 +103,43 @@ async def test_a_request_should_decrypt_properly(): assert expected_json == decrypted_json -def test_with_empty_request_path(): - clazz_simple_name = 'SampleClass' - request_path = '' - current_ts = '20230514123000' - tenant_id = '1234' - content_type = 'application/json' +def test_with_empty_request_path() -> None: + request_path = "" + current_ts = "20230514123000" + tenant_id = "1234" + content_type = "application/json" request_content = '{"key": "value"}' - user_token = 'dummy_token' - - result = get_app_verification_string(clazz_simple_name, request_path, current_ts, tenant_id, - content_type, request_content, user_token) - assert 'ff8cb13ebcce5958e7fbfe602716c653fd72ce78842be87b6d50dccede198735' == result - - -def test_with_no_request_content(): - clazz_simple_name = 'SampleClass' - request_path = '/api/v1/data' - current_ts = '20230514123000' - tenant_id = '1234' - content_type = 'application/json' - request_content = '' - user_token = 'dummy_token' - - result = get_app_verification_string(clazz_simple_name, request_path, current_ts, tenant_id, - content_type, request_content, user_token) - assert '332c85836aa9afc864282436a740eb2cc778fafd1fea74dd887c1f8de5056de0' == result + user_token = "dummy_token" # noqa: S105 + + result = get_app_verification_string( + request_path=request_path, + current_ts=current_ts, + tenant_id=tenant_id, + content_type=content_type, + request_content=request_content, + user_token=user_token, + ) + assert result == "ff8cb13ebcce5958e7fbfe602716c653fd72ce78842be87b6d50dccede198735" + + +def test_with_no_request_content() -> None: + request_path = "/api/v1/data" + current_ts = "20230514123000" + tenant_id = "1234" + content_type = "application/json" + request_content = "" + user_token = "dummy_token" # noqa: S105 + + result = get_app_verification_string( + request_path=request_path, + current_ts=current_ts, + tenant_id=tenant_id, + content_type=content_type, + request_content=request_content, + user_token=user_token, + ) + assert result == "332c85836aa9afc864282436a740eb2cc778fafd1fea74dd887c1f8de5056de0" -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_charge_info_resp.py b/tests/test_charge_info_resp.py index 0eda4c9..7a6c830 100644 --- a/tests/test_charge_info_resp.py +++ b/tests/test_charge_info_resp.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import unittest @@ -7,8 +9,8 @@ class TestChargeInfoResp(unittest.TestCase): - def test_decode_from_json(self): - as_json = '''{ + def test_decode_from_json(self) -> None: + as_json = """{ "chrgMgmtData": { "bmsChrgSts": 1, "bmsPackVol": 1649, @@ -71,11 +73,11 @@ def test_decode_from_json(self): "mileageSinceLastCharge": 0, "powerUsageSinceLastCharge": 0 } - }''' + }""" as_dict = json.loads(as_json) decoded = dacite.from_dict(ChrgMgmtDataResp, as_dict) - self.assertIsNotNone(decoded) + assert decoded is not None -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_charging_setting_resp.py b/tests/test_charging_setting_resp.py index 86bc707..3ccf568 100644 --- a/tests/test_charging_setting_resp.py +++ b/tests/test_charging_setting_resp.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import unittest @@ -7,22 +9,21 @@ class TestChargingSettingResp(unittest.TestCase): - - def test_rvc_req_sts_decoded(self): + def test_rvc_req_sts_decoded(self) -> None: sut = ChargingSettingResp( rvcReqSts=3, ) - self.assertEqual(sut.rvc_req_sts_decoded, b'\x03') + assert sut.rvc_req_sts_decoded == b"\x03" - def test_decode_from_json(self): + def test_decode_from_json(self) -> None: as_json = '{"imcuDschrgTrgtSOCResp":0,"bmsAltngChrgCrntResp":0,"bmsPackCrnt":20015,"bmsChrgTrgtSOCResp":0,"imcuDschrgTrgtSOCDspCmd":1,"rvcReqSts":3,"bmsOnBdChrgTrgtSOCDspCmd":7,"bmsAltngChrgCrntDspCmd":4,"bmsEstdElecRng":405}' as_dict = json.loads(as_json) decoded = dacite.from_dict(ChargingSettingResp, as_dict) - self.assertIsNotNone(decoded.imcuDschrgTrgtSOCResp) - self.assertIsNotNone(decoded.bmsAltngChrgCrntResp) - self.assertIsNotNone(decoded.rvcReqSts, 3) - self.assertEqual(decoded.rvc_req_sts_decoded, b'\x03') + assert decoded.imcuDschrgTrgtSOCResp is not None + assert decoded.bmsAltngChrgCrntResp is not None + assert decoded.rvcReqSts == 3 + assert decoded.rvc_req_sts_decoded == b"\x03" -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_decode_messages.py b/tests/test_decode_messages.py index e3b6993..7701ac2 100644 --- a/tests/test_decode_messages.py +++ b/tests/test_decode_messages.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import json from unittest import TestCase @@ -8,20 +10,19 @@ class TestDecodeMessages(TestCase): - def test_it_should_work_with_messageId_as_number(self): + def test_it_should_work_with_messageId_as_number(self) -> None: msg = '{"code":0,"data":{"messages":[{"readStatus":0,"messageTime":"21-01-2024 17:42:14","messageType":"323","sender":"TBOX","messageId":22233425,"vin":"LSJWHXXXXXXXXXXXX","title":"Vehicle Start","content":"The vehicle(***XXX) starts on 21-01-2024 17:42:14 GMT+01:00, please confirm that you know it."},{"readStatus":0,"messageTime":"21-01-2024 17:07:21","messageType":"323","sender":"TBOX","messageId":22230622,"vin":"LSJWHXXXXXXXXXXXX","title":"Vehicle Start","content":"The vehicle(***XXX) starts on 21-01-2024 17:07:20 GMT+01:00, please confirm that you know it."},{"readStatus":1,"messageTime":"31-12-2023 19:42:07","messageType":"801","sender":"TBOX","messageId":20127429,"vin":"LSJWHXXXXXXXXXXXX","contentIdList":[{"contentId":26}],"title":"Electric power steering warning","content":"Your vehicle (***XXX) status is abnormal, please go to vehicle status for details."}],"recordsNumber":3},"message":"success"}' msg_as_json = json.loads(msg) - decoded = dacite.from_dict(MessageResp, msg_as_json['data']) - self.assertEqual(decoded.recordsNumber, 3) - self.assertEqual(len(decoded.messages), 3) - self.assertEqual(decoded.messages[0].messageId, 22233425) - self.assertEqual( - decoded.messages[0].message_time, - datetime.datetime(year=2024, month=1, day=21, hour=17, minute=42, second=14) + decoded = dacite.from_dict(MessageResp, msg_as_json["data"]) + assert decoded.recordsNumber == 3 + assert len(decoded.messages) == 3 + assert decoded.messages[0].messageId == 22233425 + assert decoded.messages[0].message_time == datetime.datetime( + year=2024, month=1, day=21, hour=17, minute=42, second=14 ) - def test_it_should_work_with_messageId_as_string(self): + def test_it_should_work_with_messageId_as_string(self) -> None: msg = '{"code":0,"data":{"messages":[{"readStatus":0,"messageTime":"21-01-2024 17:42:14","messageType":"323","sender":"TBOX","messageId":"22233425","vin":"LSJWHXXXXXXXXXXXX","title":"Vehicle Start","content":"The vehicle(***XXX) starts on 21-01-2024 17:42:14 GMT+01:00, please confirm that you know it."},{"readStatus":0,"messageTime":"21-01-2024 17:07:21","messageType":"323","sender":"TBOX","messageId":22230622,"vin":"LSJWHXXXXXXXXXXXX","title":"Vehicle Start","content":"The vehicle(***XXX) starts on 21-01-2024 17:07:20 GMT+01:00, please confirm that you know it."},{"readStatus":1,"messageTime":"31-12-2023 19:42:07","messageType":"801","sender":"TBOX","messageId":20127429,"vin":"LSJWHXXXXXXXXXXXX","contentIdList":[{"contentId":26}],"title":"Electric power steering warning","content":"Your vehicle (***XXX) status is abnormal, please go to vehicle status for details."}],"recordsNumber":3},"message":"success"}' msg_as_json = json.loads(msg) - decoded = dacite.from_dict(MessageResp, msg_as_json['data']) - self.assertEqual(decoded.messages[0].messageId, "22233425") + decoded = dacite.from_dict(MessageResp, msg_as_json["data"]) + assert decoded.messages[0].messageId == "22233425" diff --git a/tests/test_listener.py b/tests/test_listener.py index 0dc6e94..97300f2 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -1,30 +1,34 @@ +from __future__ import annotations + +from unittest.mock import patch + import pytest from saic_ismart_client_ng.listener import SaicApiListener -@pytest.fixture() -def api_listener(): +@pytest.fixture +def api_listener() -> SaicApiListener: return SaicApiListener() @pytest.mark.asyncio -async def test_on_request(api_listener, mocker): - mocker.patch.object(api_listener, 'on_request') - path = "/test/path" - body = "test body" - headers = {"Content-Type": "application/json"} +async def test_on_request(api_listener: SaicApiListener) -> None: + with patch.object(api_listener, "on_request") as mocked_api_listener: + path = "/test/path" + body = "test body" + headers = {"Content-Type": "application/json"} - await api_listener.on_request(path, body, headers) - api_listener.on_request.assert_called_once_with(path, body, headers) + await mocked_api_listener.on_request(path, body, headers) + mocked_api_listener.on_request.assert_called_once_with(path, body, headers) @pytest.mark.asyncio -async def test_on_response(api_listener, mocker): - mocker.patch.object(api_listener, 'on_response') - path = "/test/path" - body = "test body" - headers = {"Content-Type": "application/json"} - - await api_listener.on_response(path, body, headers) - api_listener.on_response.assert_called_once_with(path, body, headers) +async def test_on_response(api_listener: SaicApiListener) -> None: + with patch.object(api_listener, "on_response") as mocked_api_listener: + path = "/test/path" + body = "test body" + headers = {"Content-Type": "application/json"} + + await mocked_api_listener.on_response(path, body, headers) + mocked_api_listener.on_response.assert_called_once_with(path, body, headers) diff --git a/tests/test_model.py b/tests/test_model.py index af8c4e6..a0e2ce5 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import unittest + from saic_ismart_client_ng.model import SaicApiConfiguration class TestSaicApiConfiguration(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.config = SaicApiConfiguration( "test_username", "test_password", @@ -12,34 +15,33 @@ def setUp(self): "https://test-uri.com", "123456", "test_region", - 5.0 + 5.0, ) - def test_username(self): - self.assertEqual(self.config.username, "test_username") + def test_username(self) -> None: + assert self.config.username == "test_username" - def test_password(self): - self.assertEqual(self.config.password, "test_password") + def test_password(self) -> None: + assert self.config.password == "test_password" # noqa: S105 - def test_username_is_email(self): - self.assertEqual(self.config.username_is_email, True) + def test_username_is_email(self) -> None: + assert self.config.username_is_email - def test_phone_country_code(self): - self.assertEqual(self.config.phone_country_code, "GB") + def test_phone_country_code(self) -> None: + assert self.config.phone_country_code == "GB" - def test_base_uri(self): - self.assertEqual(self.config.base_uri, "https://test-uri.com") + def test_base_uri(self) -> None: + assert self.config.base_uri == "https://test-uri.com" - def test_tenant_id(self): - self.assertEqual(self.config.tenant_id, "123456") + def test_tenant_id(self) -> None: + assert self.config.tenant_id == "123456" - def test_region(self): - self.assertEqual(self.config.region, "test_region") + def test_region(self) -> None: + assert self.config.region == "test_region" - def test_sms_delivery_delay(self): - self.assertEqual(self.config.sms_delivery_delay, 5.0) + def test_sms_delivery_delay(self) -> None: + assert self.config.sms_delivery_delay == 5.0 -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() - diff --git a/tests/test_vehicle_control_resp.py b/tests/test_vehicle_control_resp.py index fca5837..4b84db6 100644 --- a/tests/test_vehicle_control_resp.py +++ b/tests/test_vehicle_control_resp.py @@ -1,34 +1,37 @@ +from __future__ import annotations + import json import unittest + import dacite from saic_ismart_client_ng.api.vehicle import VehicleControlResp class TestVehicleControlResp(unittest.TestCase): - def test_rvc_req_sts_decoded(self): + def test_rvc_req_sts_decoded(self) -> None: sut = VehicleControlResp( - rvcReqSts='AQ==', + rvcReqSts="AQ==", ) - self.assertEqual(sut.rvc_req_sts_decoded, b'\x01') + assert sut.rvc_req_sts_decoded == b"\x01" - def test_rvc_req_type_decoded(self): + def test_rvc_req_type_decoded(self) -> None: sut = VehicleControlResp( - rvcReqType='Bg==', + rvcReqType="Bg==", ) - self.assertEqual(sut.rvc_req_type_decoded, b'\x06') + assert sut.rvc_req_type_decoded == b"\x06" - def test_decode_from_json(self): + def test_decode_from_json(self) -> None: as_json = '{"basicVehicleStatus":{"frontRightSeatHeatLevel":0,"steeringWheelHeatFailureReason":0,"rearRightDoor":0,"frontLeftTyrePressure":62,"sideLightStatus":0,"driverWindow":0,"rearRightTyrePressure":63,"rmtHtdRrWndSt":0,"canBusActive":1,"frontRightTyrePressure":62,"driverDoor":0,"lockStatus":1,"frontLeftSeatHeatLevel":0,"powerMode":0,"engineStatus":0,"exteriorTemperature":7,"fuelRange":3240,"extendedData2":0,"currentJourneyId":1237,"extendedData1":79,"mileage":133690,"interiorTemperature":17,"fuelLevelPrc":0,"steeringHeatLevel":0,"batteryVoltage":142,"passengerDoor":0,"clstrDspdFuelLvlSgmt":0,"mainBeamStatus":0,"remoteClimateStatus":2,"vehElecRngDsp":0,"sunroofStatus":0,"currentJourneyDistance":40,"timeOfLastCANBUSActivity":1705953523,"bonnetStatus":0,"bootStatus":0,"fuelRangeElec":3240,"rearRightWindow":0,"lastKeySeen":0,"vehicleAlarmStatus":2,"wheelTyreMonitorStatus":0,"rearLeftTyrePressure":62,"rearLeftDoor":0,"passengerWindow":0,"rearLeftWindow":0,"dippedBeamStatus":0},"gpsPosition":{"timeStamp":1705953524,"gpsStatus":2,"wayPoint":{"satellites":10,"heading":0,"position":{"altitude":115,"latitude":45485072,"longitude":9160267},"hdop":7,"speed":0}},"rvcReqType":"Bg==","failureType":0,"rvcReqSts":"AQ=="}' as_dict = json.loads(as_json) decoded = dacite.from_dict(VehicleControlResp, as_dict) - self.assertIsNotNone(decoded.basicVehicleStatus) - self.assertIsNotNone(decoded.gpsPosition) - self.assertEqual(decoded.rvcReqType, 'Bg==') - self.assertEqual(decoded.rvc_req_type_decoded, b'\x06') - self.assertIsNotNone(decoded.rvcReqSts, 'AQ==') - self.assertEqual(decoded.rvc_req_sts_decoded, b'\x01') + assert decoded.basicVehicleStatus is not None + assert decoded.gpsPosition is not None + assert decoded.rvcReqType == "Bg==" + assert decoded.rvc_req_type_decoded == b"\x06" + assert decoded.rvcReqSts is not None, "AQ==" + assert decoded.rvc_req_sts_decoded == b"\x01" -if __name__ == '__main__': +if __name__ == "__main__": unittest.main()