From 24d7ab215f3795ff7fa1a325dfbf2ea211eb2947 Mon Sep 17 00:00:00 2001 From: Alfred Date: Fri, 14 Feb 2025 10:27:47 +0100 Subject: [PATCH 01/25] Modernize actions --- .github/actions/setup-repo/action.yaml | 38 ++++++++ .github/actions/versioning/action.yaml | 71 ++++++++++++++ .github/workflows/common_get_version.yaml | 97 +++++++++++++++++++ .github/workflows/common_make_release.yaml | 56 +++++++++++ .github/workflows/common_push_version.yaml | 60 ++++++++++++ ...ch_sdk_python_manual_increase_version.yaml | 32 ++++++ .../latch_sdk_python_manual_make_release.yaml | 37 +++++++ .../latch_sdk_python_pull_request.yaml | 72 ++++++++++++++ .../workflows/latch_sdk_python_push_code.yaml | 23 +++++ .../latch_sdk_python_push_release.yaml | 36 +++++++ 10 files changed, 522 insertions(+) create mode 100644 .github/actions/setup-repo/action.yaml create mode 100644 .github/actions/versioning/action.yaml create mode 100644 .github/workflows/common_get_version.yaml create mode 100644 .github/workflows/common_make_release.yaml create mode 100644 .github/workflows/common_push_version.yaml create mode 100644 .github/workflows/latch_sdk_python_manual_increase_version.yaml create mode 100644 .github/workflows/latch_sdk_python_manual_make_release.yaml create mode 100644 .github/workflows/latch_sdk_python_pull_request.yaml create mode 100644 .github/workflows/latch_sdk_python_push_code.yaml create mode 100644 .github/workflows/latch_sdk_python_push_release.yaml diff --git a/.github/actions/setup-repo/action.yaml b/.github/actions/setup-repo/action.yaml new file mode 100644 index 0000000..71c7ae6 --- /dev/null +++ b/.github/actions/setup-repo/action.yaml @@ -0,0 +1,38 @@ +name: setup +description: Setup up repository +inputs: + python-version: + description: Python version to setup + required: false + default: "3.13" + poetry-version: + description: Poetry version to setup + required: false + default: "2.0" + +runs: + using: composite + steps: + - name: Setup python + if: inputs.python-version + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Setup poetry + if: inputs.poetry-version + uses: abatilo/actions-poetry@v4 + with: + poetry-version: ${{ inputs.poetry-version }} + + - name: Setup a local virtual environment + shell: bash + run: | + poetry config virtualenvs.create true --local + poetry config virtualenvs.in-project true --local + + - uses: actions/cache@v4 + name: Define a cache for the virtual environment based on the dependencies lock file + with: + path: ./.venv + key: venv-${{ runner.os }}-${{ runner.arch }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} \ No newline at end of file diff --git a/.github/actions/versioning/action.yaml b/.github/actions/versioning/action.yaml new file mode 100644 index 0000000..d7fe4d3 --- /dev/null +++ b/.github/actions/versioning/action.yaml @@ -0,0 +1,71 @@ +name: versioning +description: Setup up current version +inputs: + level: + description: "Version increase level" + required: false + default: "none" +outputs: + current-version: + description: "Old version" + value: ${{ steps.current-version.outputs.version }} + version: + description: "New version" + value: ${{ steps.define-version.outputs.version }} + version-no-build: + description: "New version with no build part" + value: ${{ steps.define-version.outputs.versionNoBuild }} + release-major: + description: "New major version" + value: ${{ steps.define-version.outputs.major }} + release-minor: + description: "New minor version" + value: ${{ steps.define-version.outputs.baseReleaseMinor }} + release-patch: + description: "New patch version" + value: ${{ steps.define-version.outputs.baseRelease }} + part-major: + description: "Major part of new version" + value: ${{ steps.define-version.outputs.major }} + part-minor: + description: "Minor part of new version" + value: ${{ steps.define-version.outputs.minor }} + part-patch: + description: "Patch part of new version" + value: ${{ steps.define-version.outputs.patch }} + part-prerelease-type: + description: "Prerelase type part of new version" + value: ${{ steps.define-version.outputs.prereleaseType }} + part-prerelease-number: + description: "Prerelase number part of new version" + value: ${{ steps.define-version.outputs.prereleaseNumber }} + part-build: + description: "Build part of new version" + value: ${{ steps.define-version.outputs.build }} + is-prerelease: + description: "Whether is a prerelease version or not" + value: ${{ steps.define-version.outputs.isPrerelease }} + +runs: + using: composite + steps: + - name: Store version + id: current-version + shell: bash + run: | + echo "version=$(poetry version --short)" >> "$GITHUB_OUTPUT" + + - name: Define prerelease version + id: define-version + uses: alfred82santa/action-next-version@v1 + with: + version: ${{ steps.current-version.outputs.version }} + level: ${{ inputs.level }} + versionFormat: "pep440" + releaseTagPattern: "^v([0-9](?:\\.?[0-9]+)*(?:[\\.\\-_+0-9a-zA-Z]+)?)$" + + - name: Set development version + shell: bash + if: inputs.level != 'none' + run: | + poetry version ${{ steps.define-version.outputs.version }} diff --git a/.github/workflows/common_get_version.yaml b/.github/workflows/common_get_version.yaml new file mode 100644 index 0000000..6ae3234 --- /dev/null +++ b/.github/workflows/common_get_version.yaml @@ -0,0 +1,97 @@ +name: Common get version + +on: + workflow_call: + inputs: + ref: + description: "Base branch reference" + required: false + type: string + + level: + description: "Release level" + required: false + type: string + default: none + + outputs: + version: + description: "Version" + value: ${{ jobs.get_version.outputs.version }} + version-no-build: + description: "Version with no build part" + value: ${{ jobs.get_version.outputs.version-no-build }} + release-major: + description: "Release major granularity" + value: ${{ jobs.get_version.outputs.release-major }} + release-minor: + description: "Release minor granularity" + value: ${{ jobs.get_version.outputs.release-minor }} + release-patch: + description: "Release patch granularity" + value: ${{ jobs.get_version.outputs.release-patch }} + release-ref: + description: "Release branch" + value: release/${{ jobs.get_version.outputs.release-minor }} + part-major: + description: "Major part of release version" + value: ${{ jobs.get_version.outputs.part-major }} + part-minor: + description: "Minor part of release version" + value: ${{ jobs.get_version.outputs.part-minor }} + part-patch: + description: "Patch part of release version" + value: ${{ jobs.get_version.outputs.part-patch }} + part-prerelease-type: + description: "Prerelease type part of release version" + value: ${{ jobs.get_version.outputs.part-prerelease-type }} + part-prerelease-number: + description: "Prerelease number part of release version" + value: ${{ jobs.get_version.outputs.part-prerelease-number }} + part-build: + description: "Build part of release version" + value: ${{ jobs.get_version.outputs.part-build }} + is-prerelease: + description: "Whether is a prerelease version" + value: ${{ jobs.get_version.outputs.is-prerelease }} + +permissions: + contents: read + +jobs: + get_version: + name: Get version + # The type of runner that the job will run on + runs-on: + - self-hosted + - self-hosted-org + - openshift + - Linux + + outputs: + current-version: ${{ steps.define-version.outputs.current-version }} + version: ${{ steps.define-version.outputs.version }} + version-no-build: ${{ steps.define-version.outputs.version-no-build }} + release-major: ${{ steps.define-version.outputs.release-major }} + release-minor: ${{ steps.define-version.outputs.release-minor }} + release-patch: ${{ steps.define-version.outputs.release-patch }} + part-major: ${{ steps.define-version.outputs.part-major }} + part-minor: ${{ steps.define-version.outputs.part-minor }} + part-patch: ${{ steps.define-version.outputs.part-patch }} + part-prerelease-type: ${{ steps.define-version.outputs.part-prerelease-type }} + part-prerelease-number: ${{ steps.define-version.outputs.part-prerelease-number }} + part-build: ${{ steps.define-version.outputs.part-build }} + is-prerelease: ${{ steps.define-version.outputs.is-prerelease }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.ref }} + + - uses: ./.github/actions/setup-repo + + - uses: ./.github/actions/versioning + id: define-version + with: + level: ${{ inputs.level }} diff --git a/.github/workflows/common_make_release.yaml b/.github/workflows/common_make_release.yaml new file mode 100644 index 0000000..1e3e66c --- /dev/null +++ b/.github/workflows/common_make_release.yaml @@ -0,0 +1,56 @@ +name: Common make release + +on: + workflow_call: + inputs: + level: + description: "Version increase level" + required: false + default: "none" + type: string + +jobs: + release: + name: Make release + # The type of runner that the job will run on + runs-on: + - self-hosted + - self-hosted-org + - openshift + - Linux + + outputs: + version: ${{ steps.define-version.outputs.version }} + base-release-version: ${{ steps.define-version.outputs.release-patch}} + release-ref: release/${{ steps.define-version.outputs.release-minor }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup-repo + + - uses: ./.github/actions/versioning + id: define-version + with: + level: ${{ inputs.level }} + + - name: Build package + run: | + poetry build + + - name: Publish package + run: | + poetry publish + + - name: Make release + id: make_release + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: v${{ steps.define-version.outputs.version-no-build }} + RELEASE_TITLE: Release ${{ steps.define-version.outputs.version }} + run: | + gh release create $TAG_NAME --target ${{ github.sha }} -t "$RELEASE_TITLE" ${{ (inputs.level != 'none') && '--prerelease' }} --generate-notes + echo "release=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "**Tag:** [$TAG_NAME]("$(gh release view $TAG_NAME --json url --jq ".url")")" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/common_push_version.yaml b/.github/workflows/common_push_version.yaml new file mode 100644 index 0000000..e8b2557 --- /dev/null +++ b/.github/workflows/common_push_version.yaml @@ -0,0 +1,60 @@ +# This is a basic workflow to help you get started with Actions + +name: Common push version + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the develop branch +on: + workflow_call: + inputs: + level: + description: "Version level to increase (major, minor or patch)" + required: false + default: "minor" + type: string + ref: + description: "Base branch reference" + required: false + default: "main" + type: string + +concurrency: + group: push-version-${{ inputs.ref }} + cancel-in-progress: true + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + increase_version: + name: Increase version + + runs-on: + - self-hosted + - self-hosted-org + - openshift + - Linux + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.ref }} + + - uses: ./.github/actions/setup-repo + + - uses: ./.github/actions/versioning + id: define-version + with: + level: ${{ inputs.level }} + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + title: "Bump version to ${{ steps.define-version.outputs.version }}" + branch: "task/start-version-${{ steps.define-version.outputs.version }}" + delete-branch: true + commit-message: "Bump version to ${{ steps.define-version.outputs.version }}" + body: | + :crown: *Automatic PR starting new version* + labels: automated,bot + base: ${{ inputs.ref }} + add-paths: pyproject.toml diff --git a/.github/workflows/latch_sdk_python_manual_increase_version.yaml b/.github/workflows/latch_sdk_python_manual_increase_version.yaml new file mode 100644 index 0000000..e10479e --- /dev/null +++ b/.github/workflows/latch_sdk_python_manual_increase_version.yaml @@ -0,0 +1,32 @@ +# This is a basic workflow to help you get started with Actions + +name: Increase version + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the develop branch +on: + workflow_dispatch: + inputs: + level: + description: "Version level to increase (major, minor or patch)" + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + ref: + description: "Base branch reference" + required: false + default: "develop" + type: string + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + increase_version: + uses: ./.github/workflows/common_push_version.yaml + with: + level: ${{ inputs.level }} + ref: ${{ inputs.ref }} + secrets: inherit diff --git a/.github/workflows/latch_sdk_python_manual_make_release.yaml b/.github/workflows/latch_sdk_python_manual_make_release.yaml new file mode 100644 index 0000000..596f816 --- /dev/null +++ b/.github/workflows/latch_sdk_python_manual_make_release.yaml @@ -0,0 +1,37 @@ +name: Manual make release + +on: + workflow_dispatch: + inputs: + level: + description: "Release level" + required: true + type: choice + default: dev + options: + - production + - rc + - beta + - alpha + - dev + +jobs: + make-release: + uses: ./.github/workflows/common_make_release.yaml + secrets: inherit + with: + level: ${{ inputs.level == 'production' && 'none' || inputs.level }} + + get-branch: + if: inputs.level == 'production' + uses: ./.github/workflows/common_get_version.yaml + secrets: inherit + + increase-version: + needs: + - get-branch + uses: ./.github/workflows/common_push_version.yaml + with: + level: 'patch' + ref: ${{ needs.get-branch.outputs.release-ref }} + secrets: inherit diff --git a/.github/workflows/latch_sdk_python_pull_request.yaml b/.github/workflows/latch_sdk_python_pull_request.yaml new file mode 100644 index 0000000..e7c62fe --- /dev/null +++ b/.github/workflows/latch_sdk_python_pull_request.yaml @@ -0,0 +1,72 @@ +# This is a basic workflow to help you get started with Actions + +name: Pull request checks + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the develop branch +on: + pull_request: + branches: + - develop + - master + - main + - release/* + +permissions: + contents: read + +concurrency: + group: pr-code-${{ github.base_ref }}-${{ github.ref_name }} + cancel-in-progress: true + + # A workflow run is made up of one or more jobs that can run sequentially or in parallel + +jobs: + check-style: + name: Check Python style and run tests + + runs-on: + - self-hosted + - self-hosted-org + - openshift + - Linux + + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + fail-fast: true + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup-repo + with: + python-version: ${{ matrix.python-version }} + + - name: Install development requirements + run: | + make requirements + + - name: Run checks + run: make pull-request + + success-pr: + name: Success Pull Request + if: ${{ always() }} + needs: + - check-style + runs-on: + - self-hosted + - self-hosted-org + - openshift + - Linux + steps: + - name: Check Job Status status and fail if they are red + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')}} + run: | + echo "Fail!" + exit 1 + - name: Success + run: echo "Success!" diff --git a/.github/workflows/latch_sdk_python_push_code.yaml b/.github/workflows/latch_sdk_python_push_code.yaml new file mode 100644 index 0000000..da42981 --- /dev/null +++ b/.github/workflows/latch_sdk_python_push_code.yaml @@ -0,0 +1,23 @@ +# This is a basic workflow to help you get started with Actions + +name: Push code + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the develop branch +on: + push: + branches: + - main + - master + - release/* + +permissions: + pull-requests: write + contents: write + +jobs: + make-release: + uses: ./.github/workflows/common_make_release.yaml + secrets: inherit + with: + level: ${{ (startsWith(github.ref_name, 'release/')) && 'rc' || (contains(fromJson('["master", "main"]'), github.ref_name) && 'beta') || 'alpha' }} diff --git a/.github/workflows/latch_sdk_python_push_release.yaml b/.github/workflows/latch_sdk_python_push_release.yaml new file mode 100644 index 0000000..4f16d55 --- /dev/null +++ b/.github/workflows/latch_sdk_python_push_release.yaml @@ -0,0 +1,36 @@ +name: "Push new version" + +on: + push: + branches: + - release/* + +concurrency: + group: push-new-version-to-main-from-${{ github.ref }} + cancel-in-progress: true + +permissions: + pull-requests: write + contents: write + +jobs: + get-version: + uses: ./.github/workflows/common_get_version.yaml + secrets: inherit + + get-main-version: + uses: ./.github/workflows/common_get_version.yaml + secrets: inherit + with: + ref: main + + increase-version: + needs: + - get-version + - get-main-version + if: needs.get-version.outputs.release-minor == needs.get-main-version.outputs.release-minor + uses: ./.github/workflows/common_push_version.yaml + with: + level: 'minor' + ref: main + secrets: inherit From fffbfdb11735e20f2d805a9b0eceefc3112daaa4 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 25 Feb 2025 17:01:25 +0100 Subject: [PATCH 02/25] Refactor all SDK, sync and async drivers, more tests, started a new cli utility and refactor repo stuff --- .github/workflows/common_get_version.yaml | 6 +- .github/workflows/common_make_release.yaml | 8 +- .github/workflows/common_push_version.yaml | 6 +- .../workflows/latch_sdk_python_pr_title.yaml | 21 + .../latch_sdk_python_pull_request.yaml | 13 +- .github/workflows/python-publish.yml | 42 - .gitignore | 6 + Config.mk | 6 + Makefile | 24 + Python.mk | 84 + README.md | 2 +- Version.mk | 8 + docs/Makefile | 20 + docs/make.bat | 35 + docs/source/conf.py | 27 + docs/source/index.rst | 13 + poetry.lock | 1691 +++++++++++ pyproject.toml | 72 +- requirements.txt | 2 - src/__init__.py | 1 - src/error.py | 46 - src/latch.py | 31 - src/latchapp.py | 238 -- src/latchauth.py | 266 -- src/latchresponse.py | 83 - src/latchuser.py | 61 - src/test_sdk_latch_web3.py | 74 - tests/asyncio/__init__.py | 18 + tests/asyncio/test_aiohttp.py | 224 ++ tests/asyncio/test_base.py | 95 + tests/asyncio/test_httpx.py | 236 ++ tests/factory.py | 164 ++ tests/syncio/__init__.py | 18 + tests/syncio/test_base.py | 95 + tests/syncio/test_httpx.py | 196 ++ tests/syncio/test_pure.py | 229 ++ tests/syncio/test_requests.py | 206 ++ tests/test.py | 88 - tests/test_exceptions.py | 49 + tests/test_sansio.py | 2492 +++++++++++++++++ tests/test_utils.py | 42 + 41 files changed, 6069 insertions(+), 969 deletions(-) create mode 100644 .github/workflows/latch_sdk_python_pr_title.yaml delete mode 100644 .github/workflows/python-publish.yml create mode 100644 Config.mk create mode 100644 Makefile create mode 100644 Python.mk create mode 100644 Version.mk create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 poetry.lock delete mode 100644 requirements.txt delete mode 100644 src/__init__.py delete mode 100644 src/error.py delete mode 100644 src/latch.py delete mode 100644 src/latchapp.py delete mode 100644 src/latchauth.py delete mode 100644 src/latchresponse.py delete mode 100644 src/latchuser.py delete mode 100644 src/test_sdk_latch_web3.py create mode 100644 tests/asyncio/__init__.py create mode 100644 tests/asyncio/test_aiohttp.py create mode 100644 tests/asyncio/test_base.py create mode 100644 tests/asyncio/test_httpx.py create mode 100644 tests/factory.py create mode 100644 tests/syncio/__init__.py create mode 100644 tests/syncio/test_base.py create mode 100644 tests/syncio/test_httpx.py create mode 100644 tests/syncio/test_pure.py create mode 100644 tests/syncio/test_requests.py delete mode 100644 tests/test.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_sansio.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/common_get_version.yaml b/.github/workflows/common_get_version.yaml index 6ae3234..e2e46ce 100644 --- a/.github/workflows/common_get_version.yaml +++ b/.github/workflows/common_get_version.yaml @@ -62,11 +62,7 @@ jobs: get_version: name: Get version # The type of runner that the job will run on - runs-on: - - self-hosted - - self-hosted-org - - openshift - - Linux + runs-on: ubuntu-latest outputs: current-version: ${{ steps.define-version.outputs.current-version }} diff --git a/.github/workflows/common_make_release.yaml b/.github/workflows/common_make_release.yaml index 1e3e66c..f8a2e89 100644 --- a/.github/workflows/common_make_release.yaml +++ b/.github/workflows/common_make_release.yaml @@ -13,11 +13,7 @@ jobs: release: name: Make release # The type of runner that the job will run on - runs-on: - - self-hosted - - self-hosted-org - - openshift - - Linux + runs-on: ubuntu-latest outputs: version: ${{ steps.define-version.outputs.version }} @@ -42,7 +38,7 @@ jobs: - name: Publish package run: | - poetry publish + poetry publish --username=__token__ --password=${{ secrets.PYPI_API_TOKEN }} - name: Make release id: make_release diff --git a/.github/workflows/common_push_version.yaml b/.github/workflows/common_push_version.yaml index e8b2557..b54ff23 100644 --- a/.github/workflows/common_push_version.yaml +++ b/.github/workflows/common_push_version.yaml @@ -27,11 +27,7 @@ jobs: increase_version: name: Increase version - runs-on: - - self-hosted - - self-hosted-org - - openshift - - Linux + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/latch_sdk_python_pr_title.yaml b/.github/workflows/latch_sdk_python_pr_title.yaml new file mode 100644 index 0000000..3e5d75c --- /dev/null +++ b/.github/workflows/latch_sdk_python_pr_title.yaml @@ -0,0 +1,21 @@ +name: Latch SDK Python PR conventions checker +on: + pull_request: + types: [opened, synchronize, edited, reopened] + +jobs: + pr-check: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + + steps: + - name: Check PR title convention + shell: bash + run: | + if [[ "${{ github.event.pull_request.title }}" =~ ^((feat|fix|chore|qa): ).*$ ]]; then + exit 0 + else + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/latch_sdk_python_pull_request.yaml b/.github/workflows/latch_sdk_python_pull_request.yaml index e7c62fe..6be56bc 100644 --- a/.github/workflows/latch_sdk_python_pull_request.yaml +++ b/.github/workflows/latch_sdk_python_pull_request.yaml @@ -25,12 +25,7 @@ jobs: check-style: name: Check Python style and run tests - runs-on: - - self-hosted - - self-hosted-org - - openshift - - Linux - + runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] @@ -57,11 +52,7 @@ jobs: if: ${{ always() }} needs: - check-style - runs-on: - - self-hosted - - self-hosted-org - - openshift - - Linux + runs-on: ubuntu-latest steps: - name: Check Job Status status and fail if they are red if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')}} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index 5ca1d89..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package - -on: - push: - branches: [ "master" ] - - - - -permissions: - contents: read - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 6ab2918..09b893b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ dist src/ .DS_Store +# Coverage +.coverage +coverage.xml + +# VSCode +.vscode \ No newline at end of file diff --git a/Config.mk b/Config.mk new file mode 100644 index 0000000..d09f6c6 --- /dev/null +++ b/Config.mk @@ -0,0 +1,6 @@ +# Configure this variable for your package + +PACKAGE_NAME=latch-sdk-telefonica +PACKAGE_DIR=src/latch_sdk +PACKAGE_TESTS_DIR=tests +PACKAGE_DOCS_SRC_DIR=docs/source diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee10230 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ + +include Config.mk + +.EXPORT_ALL_VARIABLES: + +help: + @echo "Recipes for ${PACKAGE_NAME} package" + @echo + @echo "General options" + @echo "-----------------------------------------------------------------------" + @echo "help: This help" + @echo + @make --quiet python-help + @echo + @echo + @make --quiet HELP_PREFIX="docs." docs.help + + +include Python.mk +include Version.mk + + +docs.%: + @${MAKE} -C docs/ HELP_PREFIX="docs." $(*) diff --git a/Python.mk b/Python.mk new file mode 100644 index 0000000..cf54a16 --- /dev/null +++ b/Python.mk @@ -0,0 +1,84 @@ +# Author: Alfred + +PACKAGE_COVERAGE=$(PACKAGE_DIR) + +ISORT_PARAMS?= + +# Minimum coverage +COVER_MIN_PERCENTAGE=50 + +PYPI_REPO?=artifactory-hi +PYPI_REPO_USERNAME?= +PYPI_REPO_PASSWORD?= + +POETRY_EXECUTABLE?=poetry +POETRY_RUN?=${POETRY_EXECUTABLE} run + + +# Recipes ************************************************************************************ +.PHONY: python-help requirements black beautify-imports beautify lint tests clean pull-request publish \ + flake autopep sort-imports + +python-help: + @echo "Python options" + @echo "-----------------------------------------------------------------------" + @echo "python-help: This help" + @echo "requirements: Install package requirements" + @echo "black: Reformat code using Black" + @echo "beautify-imports: Reformat and sort imports" + @echo "beautify: Reformat code (beautify-imports + black)" + @echo "lint: Check code format" + @echo "tests: Run tests with coverage" + @echo "clean: Clean compiled files" + @echo "pull-request: Helper to run when a pull request is made" + @echo "sort-imports: Sort imports" + @echo "build-doc-html: Build documentation HTML files" + +# Code recipes +requirements: + ${POETRY_EXECUTABLE} install --no-interaction --no-ansi + +black: + ${POETRY_RUN} ruff format . + +beautify-imports: + ${POETRY_RUN} autoflake --remove-all-unused-imports -j 4 --in-place --remove-duplicate-keys -r ${PACKAGE_DIR} ${PACKAGE_TESTS_DIR} + ${POETRY_RUN} isort ${ISORT_PARAMS} ${PACKAGE_DIR} + ${POETRY_RUN} isort ${ISORT_PARAMS} ${PACKAGE_TESTS_DIR} + ${POETRY_RUN} isort ${ISORT_PARAMS} ${PACKAGE_DOCS_SRC_DIR} + ${POETRY_RUN} absolufy-imports --never $(shell find ${PACKAGE_DIR} -not -path "*__pycache__*" | grep .py$) + ${POETRY_RUN} absolufy-imports --never $(shell find ${PACKAGE_TESTS_DIR} -not -path "*__pycache__*" | grep .py$) + +beautify: beautify-imports black + +lint: + @echo "Running flake8 tests..." + ${POETRY_RUN} ruff check . + ${POETRY_RUN} flake8 . + ${POETRY_RUN} isort -c ${ISORT_PARAMS} . + +tests: + @echo "Running tests..." + @# echo "NO TESTS" + @${POETRY_RUN} pytest -v -s --cov-report term-missing --cov-report xml --cov-fail-under=${COVER_MIN_PERCENTAGE} --cov=${PACKAGE_COVERAGE} --exitfirst + +clean: + @echo "Cleaning compiled files..." + find . | grep -E "(__pycache__|\.pyc|\.pyo)$ " | xargs rm -rf + @echo "Cleaning distribution files..." + rm -rf dist + @echo "Cleaning build files..." + rm -rf build + @echo "Cleaning egg info files..." + rm -rf ${PACKAGE_NAME}.egg-info + @echo "Cleaning coverage files..." + rm -f .coverage + + +pull-request: lint tests + +build: + ${POETRY_EXECUTABLE} build + +publish: build + ${POETRY_EXECUTABLE} publish --repository=${PYPI_REPO} --username="${PYPI_REPO_USERNAME}" --password="${PYPI_REPO_PASSWORD}" diff --git a/README.md b/README.md index 2c1dec8..db5f432 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ * Import "latch" module. ``` - import latch + import latch_sdk.latch ``` * Create a Latch object with the "Application ID" and "Secret" previously obtained. diff --git a/Version.mk b/Version.mk new file mode 100644 index 0000000..e0a2867 --- /dev/null +++ b/Version.mk @@ -0,0 +1,8 @@ + + +version: + @poetry version + +version-set.%: + @poetry version $* + @${MAKE} MAKEFLAGS=--no-print-directory version diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..a97327e --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,27 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "Latch SDK for Python" +copyright = "2025, Alfred Santacatalina" +author = "Alfred Santacatalina" +release = "3.0.0" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [] + +templates_path = ["_templates"] +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "alabaster" +html_static_path = ["_static"] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..fea6d0a --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,13 @@ +.. Latch SDK for Python documentation master file, created by + sphinx-quickstart on Wed Feb 19 16:21:24 2025. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Latch SDK for Python documentation +================================== + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..56eb378 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1691 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "absolufy-imports" +version = "0.3.1" +description = "A tool to automatically replace relative imports with absolute ones." +optional = false +python-versions = ">=3.6.1" +groups = ["dev"] +files = [ + {file = "absolufy_imports-0.3.1-py2.py3-none-any.whl", hash = "sha256:49bf7c753a9282006d553ba99217f48f947e3eef09e18a700f8a82f75dc7fc5c"}, + {file = "absolufy_imports-0.3.1.tar.gz", hash = "sha256:c90638a6c0b66826d1fb4880ddc20ef7701af34192c94faf40b95d32b59f9793"}, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.6" +description = "Happy Eyeballs for asyncio" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "aiohappyeyeballs-2.4.6-py3-none-any.whl", hash = "sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1"}, + {file = "aiohappyeyeballs-2.4.6.tar.gz", hash = "sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0"}, +] + +[[package]] +name = "aiohttp" +version = "3.11.13" +description = "Async http client/server framework (asyncio)" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a4fe27dbbeec445e6e1291e61d61eb212ee9fed6e47998b27de71d70d3e8777d"}, + {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e64ca2dbea28807f8484c13f684a2f761e69ba2640ec49dacd342763cc265ef"}, + {file = "aiohttp-3.11.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9840be675de208d1f68f84d578eaa4d1a36eee70b16ae31ab933520c49ba1325"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28a772757c9067e2aee8a6b2b425d0efaa628c264d6416d283694c3d86da7689"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b88aca5adbf4625e11118df45acac29616b425833c3be7a05ef63a6a4017bfdb"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce10ddfbe26ed5856d6902162f71b8fe08545380570a885b4ab56aecfdcb07f4"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa48dac27f41b36735c807d1ab093a8386701bbf00eb6b89a0f69d9fa26b3671"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89ce611b1eac93ce2ade68f1470889e0173d606de20c85a012bfa24be96cf867"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78e4dd9c34ec7b8b121854eb5342bac8b02aa03075ae8618b6210a06bbb8a115"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:66047eacbc73e6fe2462b77ce39fc170ab51235caf331e735eae91c95e6a11e4"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ad8f1c19fe277eeb8bc45741c6d60ddd11d705c12a4d8ee17546acff98e0802"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64815c6f02e8506b10113ddbc6b196f58dbef135751cc7c32136df27b736db09"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:967b93f21b426f23ca37329230d5bd122f25516ae2f24a9cea95a30023ff8283"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf1f31f83d16ec344136359001c5e871915c6ab685a3d8dee38e2961b4c81730"}, + {file = "aiohttp-3.11.13-cp310-cp310-win32.whl", hash = "sha256:00c8ac69e259c60976aa2edae3f13d9991cf079aaa4d3cd5a49168ae3748dee3"}, + {file = "aiohttp-3.11.13-cp310-cp310-win_amd64.whl", hash = "sha256:90d571c98d19a8b6e793b34aa4df4cee1e8fe2862d65cc49185a3a3d0a1a3996"}, + {file = "aiohttp-3.11.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b35aab22419ba45f8fc290d0010898de7a6ad131e468ffa3922b1b0b24e9d2e"}, + {file = "aiohttp-3.11.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81cba651db8795f688c589dd11a4fbb834f2e59bbf9bb50908be36e416dc760"}, + {file = "aiohttp-3.11.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f55d0f242c2d1fcdf802c8fabcff25a9d85550a4cf3a9cf5f2a6b5742c992839"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4bea08a6aad9195ac9b1be6b0c7e8a702a9cec57ce6b713698b4a5afa9c2e33"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6070bcf2173a7146bb9e4735b3c62b2accba459a6eae44deea0eb23e0035a23"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:718d5deb678bc4b9d575bfe83a59270861417da071ab44542d0fcb6faa686636"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f6b2c5b4a4d22b8fb2c92ac98e0747f5f195e8e9448bfb7404cd77e7bfa243f"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:747ec46290107a490d21fe1ff4183bef8022b848cf9516970cb31de6d9460088"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:01816f07c9cc9d80f858615b1365f8319d6a5fd079cd668cc58e15aafbc76a54"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a08ad95fcbd595803e0c4280671d808eb170a64ca3f2980dd38e7a72ed8d1fea"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c97be90d70f7db3aa041d720bfb95f4869d6063fcdf2bb8333764d97e319b7d0"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ab915a57c65f7a29353c8014ac4be685c8e4a19e792a79fe133a8e101111438e"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:35cda4e07f5e058a723436c4d2b7ba2124ab4e0aa49e6325aed5896507a8a42e"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:af55314407714fe77a68a9ccaab90fdb5deb57342585fd4a3a8102b6d4370080"}, + {file = "aiohttp-3.11.13-cp311-cp311-win32.whl", hash = "sha256:42d689a5c0a0c357018993e471893e939f555e302313d5c61dfc566c2cad6185"}, + {file = "aiohttp-3.11.13-cp311-cp311-win_amd64.whl", hash = "sha256:b73a2b139782a07658fbf170fe4bcdf70fc597fae5ffe75e5b67674c27434a9f"}, + {file = "aiohttp-3.11.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2eabb269dc3852537d57589b36d7f7362e57d1ece308842ef44d9830d2dc3c90"}, + {file = "aiohttp-3.11.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b77ee42addbb1c36d35aca55e8cc6d0958f8419e458bb70888d8c69a4ca833d"}, + {file = "aiohttp-3.11.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55789e93c5ed71832e7fac868167276beadf9877b85697020c46e9a75471f55f"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c929f9a7249a11e4aa5c157091cfad7f49cc6b13f4eecf9b747104befd9f56f2"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d33851d85537bbf0f6291ddc97926a754c8f041af759e0aa0230fe939168852b"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9229d8613bd8401182868fe95688f7581673e1c18ff78855671a4b8284f47bcb"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669dd33f028e54fe4c96576f406ebb242ba534dd3a981ce009961bf49960f117"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c1b20a1ace54af7db1f95af85da530fe97407d9063b7aaf9ce6a32f44730778"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5724cc77f4e648362ebbb49bdecb9e2b86d9b172c68a295263fa072e679ee69d"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:aa36c35e94ecdb478246dd60db12aba57cfcd0abcad43c927a8876f25734d496"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9b5b37c863ad5b0892cc7a4ceb1e435e5e6acd3f2f8d3e11fa56f08d3c67b820"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e06cf4852ce8c4442a59bae5a3ea01162b8fcb49ab438d8548b8dc79375dad8a"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5194143927e494616e335d074e77a5dac7cd353a04755330c9adc984ac5a628e"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:afcb6b275c2d2ba5d8418bf30a9654fa978b4f819c2e8db6311b3525c86fe637"}, + {file = "aiohttp-3.11.13-cp312-cp312-win32.whl", hash = "sha256:7104d5b3943c6351d1ad7027d90bdd0ea002903e9f610735ac99df3b81f102ee"}, + {file = "aiohttp-3.11.13-cp312-cp312-win_amd64.whl", hash = "sha256:47dc018b1b220c48089b5b9382fbab94db35bef2fa192995be22cbad3c5730c8"}, + {file = "aiohttp-3.11.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9862d077b9ffa015dbe3ce6c081bdf35135948cb89116e26667dd183550833d1"}, + {file = "aiohttp-3.11.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbfef0666ae9e07abfa2c54c212ac18a1f63e13e0760a769f70b5717742f3ece"}, + {file = "aiohttp-3.11.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a1f7d857c4fcf7cabb1178058182c789b30d85de379e04f64c15b7e88d66fb"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba40b7ae0f81c7029583a338853f6607b6d83a341a3dcde8bed1ea58a3af1df9"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5b95787335c483cd5f29577f42bbe027a412c5431f2f80a749c80d040f7ca9f"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7d474c5c1f0b9405c1565fafdc4429fa7d986ccbec7ce55bc6a330f36409cad"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e83fb1991e9d8982b3b36aea1e7ad27ea0ce18c14d054c7a404d68b0319eebb"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4586a68730bd2f2b04a83e83f79d271d8ed13763f64b75920f18a3a677b9a7f0"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fe4eb0e7f50cdb99b26250d9328faef30b1175a5dbcfd6d0578d18456bac567"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2a8a6bc19818ac3e5596310ace5aa50d918e1ebdcc204dc96e2f4d505d51740c"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f27eec42f6c3c1df09cfc1f6786308f8b525b8efaaf6d6bd76c1f52c6511f6a"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2a4a13dfbb23977a51853b419141cd0a9b9573ab8d3a1455c6e63561387b52ff"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:02876bf2f69b062584965507b07bc06903c2dc93c57a554b64e012d636952654"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b992778d95b60a21c4d8d4a5f15aaab2bd3c3e16466a72d7f9bfd86e8cea0d4b"}, + {file = "aiohttp-3.11.13-cp313-cp313-win32.whl", hash = "sha256:507ab05d90586dacb4f26a001c3abf912eb719d05635cbfad930bdbeb469b36c"}, + {file = "aiohttp-3.11.13-cp313-cp313-win_amd64.whl", hash = "sha256:5ceb81a4db2decdfa087381b5fc5847aa448244f973e5da232610304e199e7b2"}, + {file = "aiohttp-3.11.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:51c3ff9c7a25f3cad5c09d9aacbc5aefb9267167c4652c1eb737989b554fe278"}, + {file = "aiohttp-3.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e271beb2b1dabec5cd84eb488bdabf9758d22ad13471e9c356be07ad139b3012"}, + {file = "aiohttp-3.11.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e9eb7e5764abcb49f0e2bd8f5731849b8728efbf26d0cac8e81384c95acec3f"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baae005092e3f200de02699314ac8933ec20abf998ec0be39448f6605bce93df"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1982c98ac62c132d2b773d50e2fcc941eb0b8bad3ec078ce7e7877c4d5a2dce7"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2b25b2eeb35707113b2d570cadc7c612a57f1c5d3e7bb2b13870fe284e08fc0"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b27961d65639128336b7a7c3f0046dcc62a9443d5ef962e3c84170ac620cec47"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a01fe9f1e05025eacdd97590895e2737b9f851d0eb2e017ae9574d9a4f0b6252"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa1fb1b61881c8405829c50e9cc5c875bfdbf685edf57a76817dfb50643e4a1a"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:25de43bb3cf83ad83efc8295af7310219af6dbe4c543c2e74988d8e9c8a2a917"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe7065e2215e4bba63dc00db9ae654c1ba3950a5fff691475a32f511142fcddb"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7836587eef675a17d835ec3d98a8c9acdbeb2c1d72b0556f0edf4e855a25e9c1"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:85fa0b18558eb1427090912bd456a01f71edab0872f4e0f9e4285571941e4090"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a86dc177eb4c286c19d1823ac296299f59ed8106c9536d2b559f65836e0fb2c6"}, + {file = "aiohttp-3.11.13-cp39-cp39-win32.whl", hash = "sha256:684eea71ab6e8ade86b9021bb62af4bf0881f6be4e926b6b5455de74e420783a"}, + {file = "aiohttp-3.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:82c249f2bfa5ecbe4a1a7902c81c0fba52ed9ebd0176ab3047395d02ad96cfcb"}, + {file = "aiohttp-3.11.13.tar.gz", hash = "sha256:8ce789231404ca8fff7f693cdce398abf6d90fd5dae2b1847477196c243b1fbb"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.3.0" +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.2" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, + {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "alabaster" +version = "1.0.0" +description = "A light, configurable Sphinx theme" +optional = false +python-versions = ">=3.10" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, + {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, +] + +[[package]] +name = "anyio" +version = "4.8.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"httpx\"" +files = [ + {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, + {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +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)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"aiohttp\" and python_version < \"3.11\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "attrs" +version = "25.1.0" +description = "Classes Without Boilerplate" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, + {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "autoflake" +version = "2.3.1" +description = "Removes unused imports and unused variables" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840"}, + {file = "autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e"}, +] + +[package.dependencies] +pyflakes = ">=3.0.0" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[[package]] +name = "babel" +version = "2.17.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, +] + +[package.extras] +dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] + +[[package]] +name = "certifi" +version = "2025.1.31" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main", "docs"] +files = [ + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, +] +markers = {main = "extra == \"requests\" or extra == \"httpx\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main", "docs"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] +markers = {main = "extra == \"requests\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"cli\"" +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev", "docs"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "extra == \"cli\" and platform_system == \"Windows\"", dev = "sys_platform == \"win32\"", docs = "python_version >= \"3.11\" and sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.6.12" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, + {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"}, + {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"}, + {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"}, + {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"}, + {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"}, + {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"}, + {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"}, + {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"}, + {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"}, + {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"}, + {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"}, + {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"}, + {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"}, + {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"}, + {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"}, + {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "docutils" +version = "0.21.2" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, + {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] +markers = {main = "extra == \"httpx\" and python_version < \"3.11\"", dev = "python_version < \"3.11\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flake8" +version = "7.1.2" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +groups = ["dev"] +files = [ + {file = "flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a"}, + {file = "flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.12.0,<2.13.0" +pyflakes = ">=3.2.0,<3.3.0" + +[[package]] +name = "flake8-pyproject" +version = "1.2.3" +description = "Flake8 plug-in loading the configuration from pyproject.toml" +optional = false +python-versions = ">= 3.6" +groups = ["dev"] +files = [ + {file = "flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a"}, +] + +[package.dependencies] +Flake8 = ">=5" +TOMLi = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["pyTest", "pyTest-cov"] + +[[package]] +name = "frozenlist" +version = "1.5.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, + {file = "frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba"}, + {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab"}, + {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5"}, + {file = "frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb"}, + {file = "frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5"}, + {file = "frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45"}, + {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2"}, + {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf"}, + {file = "frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942"}, + {file = "frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d"}, + {file = "frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6"}, + {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631"}, + {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f"}, + {file = "frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8"}, + {file = "frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0"}, + {file = "frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840"}, + {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9"}, + {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03"}, + {file = "frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c"}, + {file = "frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9"}, + {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf"}, + {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e"}, + {file = "frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723"}, + {file = "frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336"}, + {file = "frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08"}, + {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0"}, + {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c"}, + {file = "frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3"}, + {file = "frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0"}, + {file = "frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3"}, + {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, +] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"httpx\"" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +description = "A minimal low-level HTTP client." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"httpx\"" +files = [ + {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, + {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"httpx\"" +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main", "docs"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] +markers = {main = "extra == \"requests\" or extra == \"aiohttp\" or extra == \"httpx\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +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"}, +] + +[[package]] +name = "isort" +version = "6.0.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.0.0-py3-none-any.whl", hash = "sha256:567954102bb47bb12e0fae62606570faacddd441e45683968c8d1734fb1af892"}, + {file = "isort-6.0.0.tar.gz", hash = "sha256:75d9d8a1438a9432a7d7b54f2d3b45cad9a4a0fdba43617d9873379704a8bdf1"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "jinja2" +version = "3.1.5" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[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 = "multidict" +version = "6.1.0" +description = "multidict implementation" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, + {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, + {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, + {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, + {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, + {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, + {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, + {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, + {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, + {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, + {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, + {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, + {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, + {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, + {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[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" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +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.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev", "docs"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] +markers = {docs = "python_version >= \"3.11\""} + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "propcache" +version = "0.3.0" +description = "Accelerated property cache" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa44f64c37cc30c9f05932c740a8b40ce359f51882c70883cc95feac842da4d"}, + {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2383a17385d9800b6eb5855c2f05ee550f803878f344f58b6e194de08b96352c"}, + {file = "propcache-0.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3e7420211f5a65a54675fd860ea04173cde60a7cc20ccfbafcccd155225f8bc"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3302c5287e504d23bb0e64d2a921d1eb4a03fb93a0a0aa3b53de059f5a5d737d"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2e068a83552ddf7a39a99488bcba05ac13454fb205c847674da0352602082f"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d913d36bdaf368637b4f88d554fb9cb9d53d6920b9c5563846555938d5450bf"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ee1983728964d6070ab443399c476de93d5d741f71e8f6e7880a065f878e0b9"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36ca5e9a21822cc1746023e88f5c0af6fce3af3b85d4520efb1ce4221bed75cc"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9ecde3671e62eeb99e977f5221abcf40c208f69b5eb986b061ccec317c82ebd0"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d383bf5e045d7f9d239b38e6acadd7b7fdf6c0087259a84ae3475d18e9a2ae8b"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8cb625bcb5add899cb8ba7bf716ec1d3e8f7cdea9b0713fa99eadf73b6d4986f"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5fa159dcee5dba00c1def3231c249cf261185189205073bde13797e57dd7540a"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7080b0159ce05f179cfac592cda1a82898ca9cd097dacf8ea20ae33474fbb25"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed7161bccab7696a473fe7ddb619c1d75963732b37da4618ba12e60899fefe4f"}, + {file = "propcache-0.3.0-cp310-cp310-win32.whl", hash = "sha256:bf0d9a171908f32d54f651648c7290397b8792f4303821c42a74e7805bfb813c"}, + {file = "propcache-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:42924dc0c9d73e49908e35bbdec87adedd651ea24c53c29cac103ede0ea1d340"}, + {file = "propcache-0.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9ddd49258610499aab83b4f5b61b32e11fce873586282a0e972e5ab3bcadee51"}, + {file = "propcache-0.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2578541776769b500bada3f8a4eeaf944530516b6e90c089aa368266ed70c49e"}, + {file = "propcache-0.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8074c5dd61c8a3e915fa8fc04754fa55cfa5978200d2daa1e2d4294c1f136aa"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b58229a844931bca61b3a20efd2be2a2acb4ad1622fc026504309a6883686fbf"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e45377d5d6fefe1677da2a2c07b024a6dac782088e37c0b1efea4cfe2b1be19b"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5060592d83454e8063e487696ac3783cc48c9a329498bafae0d972bc7816c9"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15010f29fbed80e711db272909a074dc79858c6d28e2915704cfc487a8ac89c6"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a254537b9b696ede293bfdbc0a65200e8e4507bc9f37831e2a0318a9b333c85c"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2b975528998de037dfbc10144b8aed9b8dd5a99ec547f14d1cb7c5665a43f075"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:19d36bb351ad5554ff20f2ae75f88ce205b0748c38b146c75628577020351e3c"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6032231d4a5abd67c7f71168fd64a47b6b451fbcb91c8397c2f7610e67683810"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6985a593417cdbc94c7f9c3403747335e450c1599da1647a5af76539672464d3"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a1948df1bb1d56b5e7b0553c0fa04fd0e320997ae99689488201f19fa90d2e7"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8319293e85feadbbfe2150a5659dbc2ebc4afdeaf7d98936fb9a2f2ba0d4c35c"}, + {file = "propcache-0.3.0-cp311-cp311-win32.whl", hash = "sha256:63f26258a163c34542c24808f03d734b338da66ba91f410a703e505c8485791d"}, + {file = "propcache-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:cacea77ef7a2195f04f9279297684955e3d1ae4241092ff0cfcef532bb7a1c32"}, + {file = "propcache-0.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e53d19c2bf7d0d1e6998a7e693c7e87300dd971808e6618964621ccd0e01fe4e"}, + {file = "propcache-0.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a61a68d630e812b67b5bf097ab84e2cd79b48c792857dc10ba8a223f5b06a2af"}, + {file = "propcache-0.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb91d20fa2d3b13deea98a690534697742029f4fb83673a3501ae6e3746508b5"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67054e47c01b7b349b94ed0840ccae075449503cf1fdd0a1fdd98ab5ddc2667b"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:997e7b8f173a391987df40f3b52c423e5850be6f6df0dcfb5376365440b56667"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d663fd71491dde7dfdfc899d13a067a94198e90695b4321084c6e450743b8c7"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8884ba1a0fe7210b775106b25850f5e5a9dc3c840d1ae9924ee6ea2eb3acbfe7"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa806bbc13eac1ab6291ed21ecd2dd426063ca5417dd507e6be58de20e58dfcf"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f4d7a7c0aff92e8354cceca6fe223973ddf08401047920df0fcb24be2bd5138"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9be90eebc9842a93ef8335291f57b3b7488ac24f70df96a6034a13cb58e6ff86"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bf15fc0b45914d9d1b706f7c9c4f66f2b7b053e9517e40123e137e8ca8958b3d"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a16167118677d94bb48bfcd91e420088854eb0737b76ec374b91498fb77a70e"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:41de3da5458edd5678b0f6ff66691507f9885f5fe6a0fb99a5d10d10c0fd2d64"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:728af36011bb5d344c4fe4af79cfe186729efb649d2f8b395d1572fb088a996c"}, + {file = "propcache-0.3.0-cp312-cp312-win32.whl", hash = "sha256:6b5b7fd6ee7b54e01759f2044f936dcf7dea6e7585f35490f7ca0420fe723c0d"}, + {file = "propcache-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:2d15bc27163cd4df433e75f546b9ac31c1ba7b0b128bfb1b90df19082466ff57"}, + {file = "propcache-0.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a2b9bf8c79b660d0ca1ad95e587818c30ccdb11f787657458d6f26a1ea18c568"}, + {file = "propcache-0.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0c1a133d42c6fc1f5fbcf5c91331657a1ff822e87989bf4a6e2e39b818d0ee9"}, + {file = "propcache-0.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bb2f144c6d98bb5cbc94adeb0447cfd4c0f991341baa68eee3f3b0c9c0e83767"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1323cd04d6e92150bcc79d0174ce347ed4b349d748b9358fd2e497b121e03c8"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b812b3cb6caacd072276ac0492d249f210006c57726b6484a1e1805b3cfeea0"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:742840d1d0438eb7ea4280f3347598f507a199a35a08294afdcc560c3739989d"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c6e7e4f9167fddc438cd653d826f2222222564daed4116a02a184b464d3ef05"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a94ffc66738da99232ddffcf7910e0f69e2bbe3a0802e54426dbf0714e1c2ffe"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c6ec957025bf32b15cbc6b67afe233c65b30005e4c55fe5768e4bb518d712f1"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:549722908de62aa0b47a78b90531c022fa6e139f9166be634f667ff45632cc92"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5d62c4f6706bff5d8a52fd51fec6069bef69e7202ed481486c0bc3874912c787"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:24c04f8fbf60094c531667b8207acbae54146661657a1b1be6d3ca7773b7a545"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7c5f5290799a3f6539cc5e6f474c3e5c5fbeba74a5e1e5be75587746a940d51e"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0e7c9c3cf7c276d4f6ab9af8adddc127d04e0fcabede315904d2ff76db626"}, + {file = "propcache-0.3.0-cp313-cp313-win32.whl", hash = "sha256:ee0bd3a7b2e184e88d25c9baa6a9dc609ba25b76daae942edfb14499ac7ec374"}, + {file = "propcache-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c8f7d896a16da9455f882870a507567d4f58c53504dc2d4b1e1d386dfe4588a"}, + {file = "propcache-0.3.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e560fd75aaf3e5693b91bcaddd8b314f4d57e99aef8a6c6dc692f935cc1e6bbf"}, + {file = "propcache-0.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65a37714b8ad9aba5780325228598a5b16c47ba0f8aeb3dc0514701e4413d7c0"}, + {file = "propcache-0.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:07700939b2cbd67bfb3b76a12e1412405d71019df00ca5697ce75e5ef789d829"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c0fdbdf6983526e269e5a8d53b7ae3622dd6998468821d660d0daf72779aefa"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:794c3dd744fad478b6232289c866c25406ecdfc47e294618bdf1697e69bd64a6"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4544699674faf66fb6b4473a1518ae4999c1b614f0b8297b1cef96bac25381db"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fddb8870bdb83456a489ab67c6b3040a8d5a55069aa6f72f9d872235fbc52f54"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f857034dc68d5ceb30fb60afb6ff2103087aea10a01b613985610e007053a121"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02df07041e0820cacc8f739510078f2aadcfd3fc57eaeeb16d5ded85c872c89e"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f47d52fd9b2ac418c4890aad2f6d21a6b96183c98021f0a48497a904199f006e"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9ff4e9ecb6e4b363430edf2c6e50173a63e0820e549918adef70515f87ced19a"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ecc2920630283e0783c22e2ac94427f8cca29a04cfdf331467d4f661f4072dac"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c441c841e82c5ba7a85ad25986014be8d7849c3cfbdb6004541873505929a74e"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c929916cbdb540d3407c66f19f73387f43e7c12fa318a66f64ac99da601bcdf"}, + {file = "propcache-0.3.0-cp313-cp313t-win32.whl", hash = "sha256:0c3e893c4464ebd751b44ae76c12c5f5c1e4f6cbd6fbf67e3783cd93ad221863"}, + {file = "propcache-0.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:75e872573220d1ee2305b35c9813626e620768248425f58798413e9c39741f46"}, + {file = "propcache-0.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:03c091bb752349402f23ee43bb2bff6bd80ccab7c9df6b88ad4322258d6960fc"}, + {file = "propcache-0.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46ed02532cb66612d42ae5c3929b5e98ae330ea0f3900bc66ec5f4862069519b"}, + {file = "propcache-0.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11ae6a8a01b8a4dc79093b5d3ca2c8a4436f5ee251a9840d7790dccbd96cb649"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df03cd88f95b1b99052b52b1bb92173229d7a674df0ab06d2b25765ee8404bce"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03acd9ff19021bd0567582ac88f821b66883e158274183b9e5586f678984f8fe"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd54895e4ae7d32f1e3dd91261df46ee7483a735017dc6f987904f194aa5fd14"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a67e5c04e3119594d8cfae517f4b9330c395df07ea65eab16f3d559b7068fe"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee25f1ac091def37c4b59d192bbe3a206298feeb89132a470325bf76ad122a1e"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58e6d2a5a7cb3e5f166fd58e71e9a4ff504be9dc61b88167e75f835da5764d07"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:be90c94570840939fecedf99fa72839aed70b0ced449b415c85e01ae67422c90"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49ea05212a529c2caffe411e25a59308b07d6e10bf2505d77da72891f9a05641"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:119e244ab40f70a98c91906d4c1f4c5f2e68bd0b14e7ab0a06922038fae8a20f"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:507c5357a8d8b4593b97fb669c50598f4e6cccbbf77e22fa9598aba78292b4d7"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8526b0941ec5a40220fc4dfde76aed58808e2b309c03e9fa8e2260083ef7157f"}, + {file = "propcache-0.3.0-cp39-cp39-win32.whl", hash = "sha256:7cedd25e5f678f7738da38037435b340694ab34d424938041aa630d8bac42663"}, + {file = "propcache-0.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:bf4298f366ca7e1ad1d21bbb58300a6985015909964077afd37559084590c929"}, + {file = "propcache-0.3.0-py3-none-any.whl", hash = "sha256:67dda3c7325691c2081510e92c561f465ba61b975f481735aefdfc845d2cd043"}, + {file = "propcache-0.3.0.tar.gz", hash = "sha256:a8fd93de4e1d278046345f49e2238cdb298589325849b2645d4a94c53faeffc5"}, +] + +[[package]] +name = "pycodestyle" +version = "2.12.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, + {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, +] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.3.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, + {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main", "docs"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] +markers = {main = "extra == \"requests\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "roman-numerals-py" +version = "3.0.0" +description = "Manipulate well-formed Roman numerals" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "roman_numerals_py-3.0.0-py3-none-any.whl", hash = "sha256:a1421ce66b3eab7e8735065458de3fa5c4a46263d50f9f4ac8f0e5e7701dd125"}, + {file = "roman_numerals_py-3.0.0.tar.gz", hash = "sha256:91199c4373658c03d87d9fe004f4a5120a20f6cb192be745c2377cce274ef41c"}, +] + +[package.extras] +lint = ["mypy (==1.15.0)", "pyright (==1.1.394)", "ruff (==0.9.6)"] +test = ["pytest (>=8)"] + +[[package]] +name = "ruff" +version = "0.9.6" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba"}, + {file = "ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504"}, + {file = "ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656"}, + {file = "ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d"}, + {file = "ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa"}, + {file = "ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a"}, + {file = "ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"httpx\"" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sphinx" +version = "8.2.0" +description = "Python documentation generator" +optional = false +python-versions = ">=3.11" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "sphinx-8.2.0-py3-none-any.whl", hash = "sha256:3c0a40ff71ace28b316bde7387d93b9249a3688c202181519689b66d5d0aed53"}, + {file = "sphinx-8.2.0.tar.gz", hash = "sha256:5b0067853d6e97f3fa87563e3404ebd008fce03525b55b25da90706764da6215"}, +] + +[package.dependencies] +alabaster = ">=0.7.14" +babel = ">=2.13" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +docutils = ">=0.20,<0.22" +imagesize = ">=1.3" +Jinja2 = ">=3.1" +packaging = ">=23.0" +Pygments = ">=2.17" +requests = ">=2.30.0" +roman-numerals-py = ">=1.0.0" +snowballstemmer = ">=2.2" +sphinxcontrib-applehelp = ">=1.0.7" +sphinxcontrib-devhelp = ">=1.0.6" +sphinxcontrib-htmlhelp = ">=2.0.6" +sphinxcontrib-jsmath = ">=1.0.1" +sphinxcontrib-qthelp = ">=1.0.6" +sphinxcontrib-serializinghtml = ">=1.1.9" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.394)", "pytest (>=8.0)", "ruff (==0.9.6)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250107)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"] +test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, + {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, + {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, + {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +optional = false +python-versions = ">=3.5" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, + {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["defusedxml (>=0.7.1)", "pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, + {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "types-requests" +version = "2.32.0.20241016" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, + {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, +] + +[package.dependencies] +urllib3 = ">=2" + +[[package]] +name = "typing-extensions" +version = "4.12.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.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] +markers = {main = "extra == \"aiohttp\" and python_version < \"3.11\" or extra == \"httpx\" and python_version < \"3.13\""} + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] +markers = {main = "extra == \"requests\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "yarl" +version = "1.18.3" +description = "Yet another URL library" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"aiohttp\"" +files = [ + {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34"}, + {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7"}, + {file = "yarl-1.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:602d98f2c2d929f8e697ed274fbadc09902c4025c5a9963bf4e9edfc3ab6f7ed"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c654d5207c78e0bd6d749f6dae1dcbbfde3403ad3a4b11f3c5544d9906969dde"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5094d9206c64181d0f6e76ebd8fb2f8fe274950a63890ee9e0ebfd58bf9d787b"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35098b24e0327fc4ebdc8ffe336cee0a87a700c24ffed13161af80124b7dc8e5"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3236da9272872443f81fedc389bace88408f64f89f75d1bdb2256069a8730ccc"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2c08cc9b16f4f4bc522771d96734c7901e7ebef70c6c5c35dd0f10845270bcd"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80316a8bd5109320d38eef8833ccf5f89608c9107d02d2a7f985f98ed6876990"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1e1cc06da1491e6734f0ea1e6294ce00792193c463350626571c287c9a704db"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fea09ca13323376a2fdfb353a5fa2e59f90cd18d7ca4eaa1fd31f0a8b4f91e62"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e3b9fd71836999aad54084906f8663dffcd2a7fb5cdafd6c37713b2e72be1760"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:757e81cae69244257d125ff31663249b3013b5dc0a8520d73694aed497fb195b"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1771de9944d875f1b98a745bc547e684b863abf8f8287da8466cf470ef52690"}, + {file = "yarl-1.18.3-cp310-cp310-win32.whl", hash = "sha256:8874027a53e3aea659a6d62751800cf6e63314c160fd607489ba5c2edd753cf6"}, + {file = "yarl-1.18.3-cp310-cp310-win_amd64.whl", hash = "sha256:93b2e109287f93db79210f86deb6b9bbb81ac32fc97236b16f7433db7fc437d8"}, + {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069"}, + {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193"}, + {file = "yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a"}, + {file = "yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1"}, + {file = "yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285"}, + {file = "yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2"}, + {file = "yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8"}, + {file = "yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d"}, + {file = "yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c"}, + {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:61e5e68cb65ac8f547f6b5ef933f510134a6bf31bb178be428994b0cb46c2a04"}, + {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe57328fbc1bfd0bd0514470ac692630f3901c0ee39052ae47acd1d90a436719"}, + {file = "yarl-1.18.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a440a2a624683108a1b454705ecd7afc1c3438a08e890a1513d468671d90a04e"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c7907c8548bcd6ab860e5f513e727c53b4a714f459b084f6580b49fa1b9cee"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4f6450109834af88cb4cc5ecddfc5380ebb9c228695afc11915a0bf82116789"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9ca04806f3be0ac6d558fffc2fdf8fcef767e0489d2684a21912cc4ed0cd1b8"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77a6e85b90a7641d2e07184df5557132a337f136250caafc9ccaa4a2a998ca2c"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6333c5a377c8e2f5fae35e7b8f145c617b02c939d04110c76f29ee3676b5f9a5"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b3c92fa08759dbf12b3a59579a4096ba9af8dd344d9a813fc7f5070d86bbab1"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4ac515b860c36becb81bb84b667466885096b5fc85596948548b667da3bf9f24"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:045b8482ce9483ada4f3f23b3774f4e1bf4f23a2d5c912ed5170f68efb053318"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a4bb030cf46a434ec0225bddbebd4b89e6471814ca851abb8696170adb163985"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54d6921f07555713b9300bee9c50fb46e57e2e639027089b1d795ecd9f7fa910"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1d407181cfa6e70077df3377938c08012d18893f9f20e92f7d2f314a437c30b1"}, + {file = "yarl-1.18.3-cp39-cp39-win32.whl", hash = "sha256:ac36703a585e0929b032fbaab0707b75dc12703766d0b53486eabd5139ebadd5"}, + {file = "yarl-1.18.3-cp39-cp39-win_amd64.whl", hash = "sha256:ba87babd629f8af77f557b61e49e7c7cac36f22f871156b91e10a6e9d4f829e9"}, + {file = "yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b"}, + {file = "yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.0" + +[extras] +aiohttp = ["aiohttp"] +cli = ["click"] +httpx = ["httpx"] +requests = ["requests"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9" +content-hash = "83337f01779a01c8838b1af189507ad7b1bb24765e926d20ae6cfc8c207c350d" diff --git a/pyproject.toml b/pyproject.toml index 9847d33..8684567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,81 @@ [project] name = "latch-sdk-telefonica" -version = "2.0.2" +version = "3.0.0" authors = [ { name="Telefonica Latch", email="soporte.latch@telefonica.com" }, ] -description = "latch sdk-pyhton" +description = "Latch SDK for Pyhton" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", ] -dependencies = [ - "Deprecated==1.2.13", - "python-dotenv==0.21.0" -] [project.urls] "Homepage" = "https://github.com/Telefonica/latch-sdk-python" "Bug Tracker" = "https://github.com/Telefonica/latch-sdk-python" + +[project.optional-dependencies] + +requests = ["requests (>=2.32.3,<3)"] +cli = ["click (>=8,<9)"] +aiohttp = ["aiohttp (>=3.11.13,<4.0.0)"] +httpx = ["httpx (>=0.28.1,<0.29.0)"] + +[project.scripts] +latchcli = "latch_sdk.cli:latch_sdk" + +[tool.poetry] +requires-poetry = ">=2.0" +packages = [{ from = "src", include = "latch_sdk" }] + + +[tool.poetry.group.dev.dependencies] +mypy = "^1.15.0" +flake8-pyproject = "^1.2.3" +pytest = "^8.3.4" +autoflake = "^2.3.1" +isort = "^6.0.0" +absolufy-imports = "^0.3.1" +ruff = "^0.9.6" +flake8 = "^7.1.2" +pytest-cov = "^6.0.0" +types-requests = "^2.32.0.20241016" + + +[tool.poetry.group.docs.dependencies] +sphinx = {version = "^8.2.0", python = ">=3.11"} + [build-system] -requires = ["setuptools>=61.0"] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +[tool.ruff] +exclude = [".venv/*"] + + +[tool.flake8] +exclude = [".venv/*"] +max-line-length = 120 +extend-ignore = "E251" + +[tool.isort] +profile = "black" +src_paths = ["src", "tests"] +skip_glob = [".venv/*"] +reverse_relative = true +split_on_trailing_comma = true +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true -build-backend = "setuptools.build_meta" \ No newline at end of file +[tool.coverage.run] +omit = [".venv/*", "src/latch_sdk/cli/*"] +branch = true +relative_files = false \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f56cbbb..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Deprecated -python-dotenv \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index f7d5630..0000000 --- a/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from src import error \ No newline at end of file diff --git a/src/error.py b/src/error.py deleted file mode 100644 index 9eb4a00..0000000 --- a/src/error.py +++ /dev/null @@ -1,46 +0,0 @@ -''' - This library offers an API to use Latch in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -''' - -import json - - -class Error(object): - - def __init__(self, json_data): - ''' - Constructor - ''' - - self.code = json_data['code'] - self.message = json_data['message'] - - def get_code(self): - return self.code - - def get_message(self): - return self.message - - def to_json(self): - return {"code": self.code, "message": self.message} - - def __repr__(self): - return json.dumps(self.to_json()) - - def __str__(self): - return self.__repr__() diff --git a/src/latch.py b/src/latch.py deleted file mode 100644 index 1039ebc..0000000 --- a/src/latch.py +++ /dev/null @@ -1,31 +0,0 @@ -''' - This library offers an API to use Latch in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -''' - -from latchapp import LatchApp - - -class Latch(LatchApp): - - def __init__(self, app_id, secret_key): - """ - Create an instance of the class with the Application ID and secret obtained from Eleven Paths - @param $app_id - @param $secret_key - """ - super(Latch, self).__init__(app_id, secret_key) diff --git a/src/latchapp.py b/src/latchapp.py deleted file mode 100644 index 769bb56..0000000 --- a/src/latchapp.py +++ /dev/null @@ -1,238 +0,0 @@ -""" - This library offers an API to use LatchAuth in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" -import time - -from latchauth import LatchAuth -from deprecated.sphinx import deprecated, versionchanged - - -class LatchApp(LatchAuth): - - def __init__(self, app_id, secret_key): - """ - Create an instance of the class with the Application ID and secret obtained from Eleven Paths - @param $app_id - @param $secret_key - """ - super(LatchApp, self).__init__(app_id, secret_key) - - @deprecated(version='2.0', reason="You should use another function pair_with_id") # pragma: no cover - def pairWithId(self, account_id, web3Wallet=None, web3Signature=None): - if web3Wallet is None or web3Signature is None: - return self._http("GET", self.API_PAIR_WITH_ID_URL + "/" + account_id) - else: - params = {"wallet": web3Wallet, "signature": web3Signature} - return self._http("POST", self.API_PAIR_WITH_ID_URL + "/" + account_id, None, params) - - @versionchanged(version='2.0', reason="This function has been refactored") - def pair_with_id(self, account_id, web3Wallet=None, web3Signature=None): - if web3Wallet is None or web3Signature is None: - return self._http("GET", self.API_PAIR_WITH_ID_URL + "/" + account_id) - else: - params = {"wallet": web3Wallet, "signature": web3Signature} - return self._http("POST", self.API_PAIR_WITH_ID_URL + "/" + account_id, None, params) - - def pair(self, token, web3Wallet=None, web3Signature=None): - if web3Wallet is None or web3Signature is None: - return self._http("GET", self.API_PAIR_URL + "/" + token) - else: - params = {"wallet": web3Wallet, "signature": web3Signature} - return self._http("POST", self.API_PAIR_URL + "/" + token, None, params) - - def status(self, account_id, silent=False, nootp=False): - url = self.API_CHECK_STATUS_URL + "/" + account_id - if nootp: - url += '/nootp' - if silent: - url += '/silent' - return self._http("GET", url) - - @deprecated(version='2.0', reason="You should use the function operation_status") # pragma: no cover - def operationStatus(self, account_id, operation_id, silent=False, nootp=False): - url = self.API_CHECK_STATUS_URL + "/" + account_id + "/op/" + operation_id - if nootp: - url += '/nootp' - if silent: - url += '/silent' - return self._http("GET", url) - - @versionchanged(version='2.0', reason="This function has been refactored") - def operation_status(self, account_id, operation_id, silent=False, nootp=False): - url = self.API_CHECK_STATUS_URL + "/" + account_id + "/op/" + operation_id - if nootp: - url += '/nootp' - if silent: - url += '/silent' - return self._http("GET", url) - - def unpair(self, account_id): - return self._http("GET", self.API_UNPAIR_URL + "/" + account_id) - - def lock(self, account_id, operation_id=None): - if operation_id is None: - return self._http("POST", self.API_LOCK_URL + "/" + account_id) - else: - return self._http("POST", self.API_LOCK_URL + "/" + account_id + "/op/" + operation_id) - - def unlock(self, account_id, operation_id=None): - if operation_id is None: - return self._http("POST", self.API_UNLOCK_URL + "/" + account_id) - else: - return self._http("POST", self.API_UNLOCK_URL + "/" + account_id + "/op/" + operation_id) - - def history(self, account_id, from_t=0, to_t=None): - if to_t is None: - to_t = int(round(time.time() * 1000)) - return self._http("GET", self.API_HISTORY_URL + "/" + account_id + "/" + str(from_t) + "/" + str(to_t)) - - @deprecated(version='2.0', reason="You should use the function create_operation") # pragma: no cover - def createOperation(self, parent_id, name, two_factor, lock_on_request): - params = {'parentId': parent_id, 'name': name, 'two_factor': two_factor, 'lock_on_request': lock_on_request} - return self._http("PUT", self.API_OPERATION_URL, None, params) - - @versionchanged(version='2.0', reason="This function has been refactored") - def create_operation(self, parent_id, name, two_factor, lock_on_request): - params = {'parentId': parent_id, 'name': name, 'two_factor': two_factor, 'lock_on_request': lock_on_request} - return self._http("PUT", self.API_OPERATION_URL, None, params) - - @deprecated(version='2.0', reason="You should use the function update_operation") # pragma: no cover - def updateOperation(self, operation_id, name, two_factor, lock_on_request): - params = {'name': name, 'two_factor': two_factor, 'lock_on_request': lock_on_request} - return self._http("POST", self.API_OPERATION_URL + "/" + operation_id, None, params) - - @versionchanged(version='2.0', reason="This function has been refactored") - def update_operation(self, operation_id, name, two_factor, lock_on_request): - params = {'name': name, 'two_factor': two_factor, 'lock_on_request': lock_on_request} - return self._http("POST", self.API_OPERATION_URL + "/" + operation_id, None, params) - - @deprecated(version='2.0', reason="You should use the function delete_operation") # pragma: no cover - def deleteOperation(self, operation_id): - return self._http("DELETE", self.API_OPERATION_URL + "/" + operation_id) - - @versionchanged(version='2.0', reason="This function has been refactored") - def delete_operation(self, operation_id): - return self._http("DELETE", self.API_OPERATION_URL + "/" + operation_id) - - @deprecated(version='2.0', reason="You should use the function get_operations") # pragma: no cover - def getOperations(self, operation_id=None): - if operation_id is None: - return self._http("GET", self.API_OPERATION_URL) - else: - return self._http("GET", self.API_OPERATION_URL + "/" + operation_id) - - @versionchanged(version='2.0', reason="This function has been refactored") - def get_operations(self, operation_id=None): - if operation_id is None: - return self._http("GET", self.API_OPERATION_URL) - else: - return self._http("GET", self.API_OPERATION_URL + "/" + operation_id) - - @deprecated(version='2.0', reason="You should use the function get_instances") # pragma: no cover - def getInstances(self, account_id, operation_id=None): - if operation_id is None: - return self._http("GET", self.API_INSTANCE_URL + "/" + account_id) - else: - return self._http("GET", self.API_INSTANCE_URL + "/" + account_id + "/op/" + operation_id) - - @versionchanged(version='2.0', reason="This function has been refactored") - def get_instances(self, account_id, operation_id=None): - if operation_id is None: - return self._http("GET", self.API_INSTANCE_URL + "/" + account_id) - else: - return self._http("GET", self.API_INSTANCE_URL + "/" + account_id + "/op/" + operation_id) - - @deprecated(version='2.0', reason="You should use the function instance_status") # pragma: no cover - def instanceStatus(self, instance_id, account_id, operation_id=None, silent=False, nootp=False): - if operation_id is None: - url = self.API_CHECK_STATUS_URL + "/" + account_id + "/i/" + instance_id - else: - url = self.API_CHECK_STATUS_URL + "/" + account_id + "/op/" + operation_id + "/i/" + instance_id - if nootp: - url += '/nootp' - if silent: - url += '/silent' - return self._http("GET", url) - - @versionchanged(version='2.0', reason="This function has been refactored") - def instance_status(self, instance_id, account_id, operation_id=None, silent=False, nootp=False): - if operation_id is None: - url = self.API_CHECK_STATUS_URL + "/" + account_id + "/i/" + instance_id - else: - url = self.API_CHECK_STATUS_URL + "/" + account_id + "/op/" + operation_id + "/i/" + instance_id - if nootp: - url += '/nootp' - if silent: - url += '/silent' - return self._http("GET", url) - - @deprecated(version='2.0', reason="You should use the function create_instance") # pragma: no cover - def createInstance(self, name, account_id, operation_id=None): - # Only one at a time - params = {'instances': name} - if operation_id is None: - return self._http("PUT", self.API_INSTANCE_URL + '/' + account_id, None, params) - else: - return self._http("PUT", self.API_INSTANCE_URL + '/' + account_id + '/op/' + operation_id, None, params) - - @versionchanged(version='2.0', reason="This function has been refactored") - def create_instance(self, name, account_id, operation_id=None): - # Only one at a time - params = {'instances': name} - if operation_id is None: - return self._http("PUT", self.API_INSTANCE_URL + '/' + account_id, None, params) - else: - return self._http("PUT", self.API_INSTANCE_URL + '/' + account_id + '/op/' + operation_id, None, params) - - @deprecated(version='2.0', reason="You should use the function update_instance") # pragma: no cover - def updateInstance(self, instance_id, account_id, operation_id, name, two_factor, lock_on_request): - params = {'name': name, 'two_factor': two_factor, 'lock_on_request': lock_on_request} - - if operation_id is None: - return self._http("POST", self.API_INSTANCE_URL + "/" + account_id + '/i/' + instance_id, None, params) - else: - return self._http("POST", - self.API_OPERATION_URL + "/" + account_id + '/op/' + operation_id + '/i/' + instance_id, - None, params) - - @versionchanged(version='2.0', reason="This function has been refactored") - def update_instance(self, instance_id, account_id, operation_id, name, two_factor, lock_on_request): - params = {'name': name, 'two_factor': two_factor, 'lock_on_request': lock_on_request} - - if operation_id is None: - return self._http("POST", self.API_INSTANCE_URL + "/" + account_id + '/i/' + instance_id, None, params) - else: - return self._http("POST", - self.API_OPERATION_URL + "/" + account_id + '/op/' + operation_id + '/i/' + instance_id, - None, params) - - @deprecated(version='2.0', reason="You should use the function delete_instance") # pragma: no cover - def deleteInstance(self, instance_id, account_id, operation_id=None): - if operation_id is None: - return self._http("DELETE", self.API_INSTANCE_URL + "/" + account_id + '/i/' + instance_id) - else: - return self._http("DELETE", - self.API_INSTANCE_URL + "/" + account_id + "/op/" + operation_id + "/i/" + instance_id) - - @versionchanged(version='2.0', reason="This function has been refactored") - def delete_instance(self, instance_id, account_id, operation_id=None): - if operation_id is None: - return self._http("DELETE", self.API_INSTANCE_URL + "/" + account_id + '/i/' + instance_id) - else: - return self._http("DELETE", - self.API_INSTANCE_URL + "/" + account_id + "/op/" + operation_id + "/i/" + instance_id) diff --git a/src/latchauth.py b/src/latchauth.py deleted file mode 100644 index d3b9899..0000000 --- a/src/latchauth.py +++ /dev/null @@ -1,266 +0,0 @@ -""" - This library offers an API to use LatchAuth in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - -from latchresponse import LatchResponse -import logging -import time - - -class LatchAuth(object): - API_VERSION = "1.0" - API_HOST = "latch.telefonica.com" - API_PORT = 443 - API_HTTPS = True - API_PROXY = None - API_PROXY_PORT = None - API_CHECK_STATUS_URL = "/api/" + API_VERSION + "/status" - API_PAIR_URL = "/api/" + API_VERSION + "/pair" - API_PAIR_WITH_ID_URL = "/api/" + API_VERSION + "/pairWithId" - API_UNPAIR_URL = "/api/" + API_VERSION + "/unpair" - API_LOCK_URL = "/api/" + API_VERSION + "/lock" - API_UNLOCK_URL = "/api/" + API_VERSION + "/unlock" - API_HISTORY_URL = "/api/" + API_VERSION + "/history" - API_OPERATION_URL = "/api/" + API_VERSION + "/operation" - API_SUBSCRIPTION_URL = "/api/" + API_VERSION + "/subscription" - API_APPLICATION_URL = "/api/" + API_VERSION + "/application" - API_INSTANCE_URL = "/api/" + API_VERSION + "/instance" - - AUTHORIZATION_HEADER_NAME = "Authorization" - DATE_HEADER_NAME = "X-11Paths-Date" - AUTHORIZATION_METHOD = "11PATHS" - AUTHORIZATION_HEADER_FIELD_SEPARATOR = " " - - UTC_STRING_FORMAT = "%Y-%m-%d %H:%M:%S" - - X_11PATHS_HEADER_PREFIX = "X-11paths-" - X_11PATHS_HEADER_SEPARATOR = ":" - - @staticmethod - def set_host(host): - """ - @param $host The host to be connected with (http://hostname) or (https://hostname) - """ - if host.startswith("http://"): - LatchAuth.API_HOST = host[len("http://"):] - LatchAuth.API_PORT = 80 - LatchAuth.API_HTTPS = False - elif host.startswith("https://"): - LatchAuth.API_HOST = host[len("https://"):] - LatchAuth.API_PORT = 443 - LatchAuth.API_HTTPS = True - - @staticmethod - def set_proxy(proxy, port): - """ - Enable using a Proxy to connect through - @param $proxy The proxy server - @param $port The proxy port number - """ - LatchAuth.API_PROXY = proxy - LatchAuth.API_PROXY_PORT = port - - @staticmethod - def get_part_from_header(part, header): - """ - The custom header consists of three parts, the method, the appId and the signature. - This method returns the specified part if it exists. - @param $part The zero indexed part to be returned - @param $header The HTTP header value from which to extract the part - @return string the specified part from the header or an empty string if not existent - """ - if header: - parts = header.split(LatchAuth.AUTHORIZATION_HEADER_FIELD_SEPARATOR) - if len(parts) >= part: - return parts[part] - return "" - - @staticmethod - def get_auth_method_from_header(authorization_header): - """ - @param $authorization_header Authorization HTTP Header - @return string the Authorization method. Typical values are "Basic", "Digest" or "11PATHS" - """ - return LatchAuth.get_part_from_header(0, authorization_header) - - @staticmethod - def get_appId_from_header(authorization_header): - """ - @param $authorization_header Authorization HTTP Header - @return string the requesting application Id. Identifies the application using the API - """ - return LatchAuth.get_part_from_header(1, authorization_header) - - @staticmethod - def get_signature_from_header(authorization_header): - """ - @param $authorization_header Authorization HTTP Header - @return string the signature of the current request. Verifies the identity of the application using the API - """ - return LatchAuth.get_part_from_header(2, authorization_header) - - @staticmethod - def get_current_UTC(): - """ - @return a string representation of the current time in UTC to be used in a Date HTTP Header - """ - return time.strftime(LatchAuth.UTC_STRING_FORMAT, time.gmtime()) - - def __init__(self, appId, secretKey): - """ - Create an instance of the class with the Application ID and secret obtained from Eleven Paths - @param $appId - @param $secretKey - """ - self.appId = appId - self.secretKey = secretKey - - def _http(self, method, url, x_headers=None, params=None): - """ - HTTP Request to the specified API endpoint - @param $string $url - @param $string $x_headers - @return LatchResponse - """ - try: - # Try to use the new Python3 HTTP library if available - import http.client as http - import urllib.parse as urllib - except: - # Must be using Python2 so use the appropriate library - import httplib as http - import urllib - - auth_headers = self.authentication_headers(method, url, x_headers, None, params) - if LatchAuth.API_PROXY != None: - if LatchAuth.API_HTTPS: - conn = http.HTTPSConnection(LatchAuth.API_PROXY, LatchAuth.API_PROXY_PORT) - conn.set_tunnel(LatchAuth.API_HOST, LatchAuth.API_PORT) - else: - conn = http.HTTPConnection(LatchAuth.API_PROXY, LatchAuth.API_PROXY_PORT) - url = "http://" + LatchAuth.API_HOST + url - else: - if LatchAuth.API_HTTPS: - conn = http.HTTPSConnection(LatchAuth.API_HOST, LatchAuth.API_PORT) - else: - conn = http.HTTPConnection(LatchAuth.API_HOST, LatchAuth.API_PORT) - - try: - all_headers = auth_headers - if method == "POST" or method == "PUT": - all_headers["Content-type"] = "application/x-www-form-urlencoded" - if params is not None: - parameters = urllib.urlencode(params) - - conn.request(method, url, parameters, headers=all_headers) - else: - conn.request(method, url, headers=auth_headers) - - response = conn.getresponse() - - response_data = response.read().decode('utf8') - conn.close() - ret = LatchResponse(response_data) - except: - ret = None - - return ret - - def sign_data(self, data): - """ - @param $data the string to sign - @return string base64 encoding of the HMAC-SHA1 hash of the data parameter using {@code secretKey} as cipher key. - """ - from hashlib import sha1 - import hmac - import binascii - - sha1_hash = hmac.new(self.secretKey.encode(), data.encode(), sha1) - return binascii.b2a_base64(sha1_hash.digest())[:-1].decode('utf8') - - def authentication_headers(self, http_method, query_string, x_headers=None, utc=None, params=None): - """ - Calculate the authentication headers to be sent with a request to the API - @param $http_method the HTTP Method, currently only GET is supported - @param $query_string the urlencoded string including the path (from the first forward slash) and the parameters - @param $x_headers HTTP headers specific to the 11-paths API. null if not needed. - @param $utc the Universal Coordinated Time for the Date HTTP header - @return array a map with the Authorization and Date headers needed to sign a Latch API request - """ - if not utc: - utc = LatchAuth.get_current_UTC() - - utc = utc.strip() - - # logging.debug(http_method) - # logging.debug(query_string) - # logging.debug(utc) - - string_to_sign = (http_method.upper().strip() + "\n" + - utc + "\n" + - self.get_serialized_headers(x_headers) + "\n" + - query_string.strip()) - - if params is not None: - string_to_sign = string_to_sign + "\n" + self.get_serialized_params(params) - - authorization_header = (LatchAuth.AUTHORIZATION_METHOD + LatchAuth.AUTHORIZATION_HEADER_FIELD_SEPARATOR + - self.appId + LatchAuth.AUTHORIZATION_HEADER_FIELD_SEPARATOR + - self.sign_data(string_to_sign)) - - headers = dict() - headers[LatchAuth.AUTHORIZATION_HEADER_NAME] = authorization_header - headers[LatchAuth.DATE_HEADER_NAME] = utc - return headers - - def get_serialized_headers(self, x_headers): - """ - Prepares and returns a string ready to be signed from the 11-paths specific HTTP headers received - @param $x_headers a non neccesarily ordered map (array without duplicates) of the HTTP headers to be ordered. - @return string The serialized headers, an empty string if no headers are passed, or None if there's a problem such as non 11paths specific headers - """ - if x_headers: - headers = dict((k.lower(), v) for k, v in x_headers.iteritems()) - headers.sort() - serialized_headers = "" - for key, value in headers: - if not key.startsWith(LatchAuth.X_11PATHS_HEADER_PREFIX.lower()): - logging.error( - "Error serializing headers. Only specific " + LatchAuth.X_11PATHS_HEADER_PREFIX + " headers need to be singed") - return None - serialized_headers += key + LatchAuth.X_11PATHS_HEADER_SEPARATOR + value + " " - return serialized_headers.strip() - else: - return "" - - def get_serialized_params(self, params): - try: - # Try to use the new Python3 HTTP library if available - import http.client as http - import urllib.parse as urllib - except: - # Must be using Python2 so use the appropriate library - import httplib as http - import urllib - if params: - serialized_params = "" - for key in sorted(params): - serialized_params += key + "=" + urllib.quote_plus(params[key]) + "&" - return serialized_params.strip("&") - else: - return "" diff --git a/src/latchresponse.py b/src/latchresponse.py deleted file mode 100644 index e57c791..0000000 --- a/src/latchresponse.py +++ /dev/null @@ -1,83 +0,0 @@ -""" - This library offers an API to use Latch in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - -from error import Error -import json - - -class LatchResponse(object): - """ - This class models a response from any of the endpoints in the Latch API. - It consists of a "data" and an "error" elements. Although normally only one of them will be - present, they are not mutually exclusive, since errors can be non fatal, and therefore a response - could have valid information in the data field and at the same time inform of an error. - """ - - def __init__(self, json_string): - """ - @param $json a json string received from one of the methods of the Latch API - """ - json_object = json.loads(json_string) - if "data" in json_object: - self.data = json_object["data"] - else: - self.data = "" - - if "error" in json_object: - self.error = Error(json_object["error"]) - else: - self.error = "" - - def get_data(self): - """ - @return JsonObject the data part of the API response - """ - return self.data - - def set_data(self, data): - """ - @param $data the data to include in the API response - """ - self.data = json.loads(data) - - def get_error(self): - """ - @return Error the error part of the API response, consisting of an error code and an error message - """ - return self.error - - def set_error(self, error): - """ - @param $error an error to include in the API response - """ - self.error = Error(error) - - def to_json(self): - """ - @return a Json object with the data and error parts set if they exist - """ - json_response = {} - - if hasattr(self, "data"): - json_response["data"] = self.data - - if hasattr(self, "error"): - json_response["error"] = self.error - - return json_response \ No newline at end of file diff --git a/src/latchuser.py b/src/latchuser.py deleted file mode 100644 index 2085398..0000000 --- a/src/latchuser.py +++ /dev/null @@ -1,61 +0,0 @@ -""" - This library offers an API to use LatchAuth in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - -from latchauth import LatchAuth - - -class LatchUser(LatchAuth): - - def __init__(self, user_id, secret_key): - """ - Create an instance of the class with the User ID and secret obtained from Eleven Paths - @param $user_id - @param $secret_key - """ - super(LatchUser, self).__init__(user_id, secret_key) - - def get_subscription(self): - return self._http("GET", self.API_SUBSCRIPTION_URL) - - def create_application(self, name, two_factor, lock_on_request, contact_phone, contact_email): - params = { - 'name': name, - 'two_factor': two_factor, - 'lock_on_request': lock_on_request, - 'contactPhone': contact_phone, - 'contactEmail': contact_email - } - - return self._http("PUT", self.API_APPLICATION_URL, None, params) - - def remove_application(self, application_id): - return self._http("DELETE", self.API_APPLICATION_URL + "/" + application_id) - - def get_applications(self): - return self._http("GET", self.API_APPLICATION_URL) - - def update_application(self, application_id, name, two_factor, lock_on_request, contact_phone, contact_email): - params = { - 'name': name, - 'two_factor': two_factor, - 'lock_on_request': lock_on_request, - 'contactPhone': contact_phone, - 'email': contact_email - } - return self._http("POST", self.API_APPLICATION_URL + "/" + application_id, None, params) \ No newline at end of file diff --git a/src/test_sdk_latch_web3.py b/src/test_sdk_latch_web3.py deleted file mode 100644 index e2c6ab3..0000000 --- a/src/test_sdk_latch_web3.py +++ /dev/null @@ -1,74 +0,0 @@ -""" - This library offers an API to use LatchAuth in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" -import sys -import os -import logging - -from src import latch - -logging.basicConfig() - -current = os.path.dirname(os.path.realpath(__file__)) -parent = os.path.dirname(current) -sys.path.append(parent) - -APP_ID = "" -SECRET_KEY = "" - -WEB3WALLET = "" -WEB3SIGNATURE = "" - -ACCOUNT_ID = "" - - -def example_pair(): - api = latch.Latch(APP_ID, SECRET_KEY) - pairing_code = input("Enter the pairing code: ") - response = api.pair(pairing_code, WEB3WALLET, WEB3SIGNATURE) - if response.get_error() != "": - logging.error( - f"Error in PAIR request with error_code: {response.get_error().get_code()}" - f" and message: {response.get_error().get_message()}") - else: - account_id = response.data.get("accountId") - logging.info(f"AccountId: {account_id}") - get_status(api, account_id) - return account_id - - -def example_unpair(account_id): - api = latch.Latch(APP_ID, SECRET_KEY) - response = api.unpair(account_id) - logging.info(f"Status after unpair: {response.data}") - get_status(api, account_id) - - -def get_status(api, account_id): - response = api.status(account_id) - if response.get_error() != "": - logging.error( - f"Error in get_status request with error_code: {response.get_error().get_code()}" - f" and message: {response.get_error().get_message()}") - else: - logging.info(f"Status: {response.data}") - - -if __name__ == '__main__': - example_pair() - # example_unpair(ACCOUNT_ID) diff --git a/tests/asyncio/__init__.py b/tests/asyncio/__init__.py new file mode 100644 index 0000000..76d921a --- /dev/null +++ b/tests/asyncio/__init__.py @@ -0,0 +1,18 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" diff --git a/tests/asyncio/test_aiohttp.py b/tests/asyncio/test_aiohttp.py new file mode 100644 index 0000000..5ceb7eb --- /dev/null +++ b/tests/asyncio/test_aiohttp.py @@ -0,0 +1,224 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from typing import Any +from unittest import IsolatedAsyncioTestCase +from unittest.mock import ANY, MagicMock, Mock, patch + +from aiohttp import ClientResponse, ClientSession + +from latch_sdk.asyncio.aiohttp import Latch + +from ..factory import ResponseFactory + + +def _prepare_mocks( + client_mock: Mock, data: dict[str, Any] +) -> tuple[MagicMock, MagicMock]: + instance_mock = MagicMock(ClientSession) + + response_mock = MagicMock(ClientResponse) + response_mock.__aenter__.return_value = response_mock + response_mock.json.return_value = data + response_mock.raise_for_status = Mock() + response_mock.raise_for_status.return_value = None + + client_mock.return_value = instance_mock + instance_mock.request.return_value = response_mock + + return instance_mock, response_mock + + +class LatchTestCase(IsolatedAsyncioTestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + @patch("latch_sdk.asyncio.aiohttp.ClientSession") + async def test_http_get( + self, + client_mock: Mock, + ): + instance_mock, response_mock = _prepare_mocks( + client_mock, ResponseFactory.status_on() + ) + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + response_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.asyncio.aiohttp.ClientSession") + async def test_http_post( + self, + client_mock: Mock, + ): + instance_mock, response_mock = _prepare_mocks( + client_mock, ResponseFactory.pair() + ) + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + await latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + client_mock.assert_called() + response_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "POST", + "http://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.asyncio.aiohttp.ClientSession") + async def test_https_get( + self, + client_mock: Mock, + ): + instance_mock, response_mock = _prepare_mocks( + client_mock, ResponseFactory.status_on() + ) + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + response_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.asyncio.aiohttp.ClientSession") + async def test_https_post( + self, + client_mock: Mock, + ): + instance_mock, response_mock = _prepare_mocks( + client_mock, ResponseFactory.pair() + ) + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + await latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + client_mock.assert_called() + response_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "POST", + "https://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.asyncio.aiohttp.ClientSession") + async def test_proxy_http_get( + self, + client_mock: Mock, + ): + instance_mock, response_mock = _prepare_mocks( + client_mock, ResponseFactory.status_on() + ) + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com:8443" + + client_mock.assert_called_with(proxy="http://proxy.bar.com:8443") + latch.host = "http://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + response_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.asyncio.aiohttp.ClientSession") + async def test_proxy_no_port_http_get( + self, + client_mock: Mock, + ): + instance_mock, response_mock = _prepare_mocks( + client_mock, ResponseFactory.status_on() + ) + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com" + + client_mock.assert_called_with(proxy="http://proxy.bar.com") + latch.host = "http://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + response_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) diff --git a/tests/asyncio/test_base.py b/tests/asyncio/test_base.py new file mode 100644 index 0000000..7adfa11 --- /dev/null +++ b/tests/asyncio/test_base.py @@ -0,0 +1,95 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock + +from latch_sdk.asyncio.base import BaseLatch, LatchSDK +from latch_sdk.exceptions import ApplicationAlreadyPaired, TokenNotFound +from latch_sdk.response import LatchResponse + +from ..factory import ResponseFactory + + +class LatchSDKTestCase(IsolatedAsyncioTestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + def setUp(self) -> None: + self.core = AsyncMock(BaseLatch) + self.latch_sdk = LatchSDK(self.core) + + return super().setUp() + + async def test_pair(self): + self.core.pair.return_value = LatchResponse( + ResponseFactory.pair("test_account") + ) + self.assertEqual(await self.latch_sdk.pair("terwrw"), "test_account") + + async def test_pair_error_206_token_expired(self): + self.core.pair.return_value = LatchResponse( + ResponseFactory.pair_error_206_token_expired() + ) + + with self.assertRaises(TokenNotFound): + await self.latch_sdk.pair("terwrw") + + async def test_pair_error_205_already_paired(self): + self.core.pair.return_value = LatchResponse( + ResponseFactory.pair_error_205_already_paired("test_account") + ) + + with self.assertRaises(ApplicationAlreadyPaired) as ex: + await self.latch_sdk.pair("terwrw") + + self.assertEqual(ex.exception.account_id, "test_account") # type: ignore + + """ + pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] + pair_with_id = wrap_method( + response_pair, + BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] + ) + + unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] + + status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] + + operation_status = wrap_method( + response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] + ) + + lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] + unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] + + create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] + update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] + delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] + get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] + + get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] + instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] + create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] + update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] + delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] + + history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] + + """ diff --git a/tests/asyncio/test_httpx.py b/tests/asyncio/test_httpx.py new file mode 100644 index 0000000..32da2ff --- /dev/null +++ b/tests/asyncio/test_httpx.py @@ -0,0 +1,236 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from unittest import IsolatedAsyncioTestCase +from unittest.mock import ANY, AsyncMock, Mock, patch + +from httpx import AsyncClient, Response + +from latch_sdk.asyncio.httpx import Latch + +from ..factory import ResponseFactory + + +class LatchTestCase(IsolatedAsyncioTestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + @patch("latch_sdk.asyncio.httpx.AsyncClient") + async def test_http_get( + self, + client_mock: Mock, + ): + instance_mock = AsyncMock(AsyncClient) + + response_mock = Mock(Response) + response_mock.json.return_value = ResponseFactory.status_on() + + client_mock.return_value = instance_mock + instance_mock.__aenter__.return_value = instance_mock + instance_mock.request.return_value = response_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + instance_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.asyncio.httpx.AsyncClient") + async def test_http_post( + self, + client_mock: Mock, + ): + instance_mock = AsyncMock(AsyncClient) + + response_mock = Mock(Response) + response_mock.json.return_value = ResponseFactory.pair() + + client_mock.return_value = instance_mock + instance_mock.__aenter__.return_value = instance_mock + instance_mock.request.return_value = response_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + await latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + client_mock.assert_called() + instance_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "POST", + "http://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.asyncio.httpx.AsyncClient") + async def test_https_get( + self, + client_mock: Mock, + ): + instance_mock = AsyncMock(AsyncClient) + + response_mock = Mock(Response) + response_mock.json.return_value = ResponseFactory.status_on() + + client_mock.return_value = instance_mock + instance_mock.__aenter__.return_value = instance_mock + instance_mock.request.return_value = response_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + instance_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.asyncio.httpx.AsyncClient") + async def test_https_post( + self, + client_mock: Mock, + ): + instance_mock = AsyncMock(AsyncClient) + + response_mock = Mock(Response) + response_mock.json.return_value = ResponseFactory.pair() + + client_mock.return_value = instance_mock + instance_mock.__aenter__.return_value = instance_mock + instance_mock.request.return_value = response_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + await latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + client_mock.assert_called() + instance_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "POST", + "https://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.asyncio.httpx.AsyncClient") + async def test_proxy_http_get( + self, + client_mock: Mock, + ): + instance_mock = AsyncMock(AsyncClient) + + response_mock = Mock(Response) + response_mock.json.return_value = ResponseFactory.status_on() + + client_mock.return_value = instance_mock + instance_mock.__aenter__.return_value = instance_mock + instance_mock.request.return_value = response_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com:8443" + + client_mock.assert_called_with(proxy="http://proxy.bar.com:8443") + latch.host = "http://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + instance_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.asyncio.httpx.AsyncClient") + async def test_proxy_no_port_http_get( + self, + client_mock: Mock, + ): + instance_mock = AsyncMock(AsyncClient) + + response_mock = Mock(Response) + response_mock.json.return_value = ResponseFactory.status_on() + + client_mock.return_value = instance_mock + instance_mock.__aenter__.return_value = instance_mock + instance_mock.request.return_value = response_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com" + + client_mock.assert_called_with(proxy="http://proxy.bar.com") + latch.host = "http://foo.bar.com" + + await latch.status("account_id_3454657656454") + + client_mock.assert_called() + instance_mock.__aenter__.assert_called_once() + instance_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) diff --git a/tests/factory.py b/tests/factory.py new file mode 100644 index 0000000..557991a --- /dev/null +++ b/tests/factory.py @@ -0,0 +1,164 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from typing import Literal + + +class ResponseFactory: + @classmethod + def no_data(cls): + return {} + + @classmethod + def error(cls, code: int, message: str): + return {"error": {"code": code, "message": message}} + + @classmethod + def pair( + cls, + account_id: str = "ngcmkRi38JWiJ8XmeNuDThdcUTYRUfd6ryE9EeRGZdn8zjHXpvFHEzLJpVKguzCw", + ): + return {"data": {"accountId": account_id}} + + @classmethod + def pair_error_205_already_paired( + cls, + account_id: str = "ngcmkRi38JWiJ8XmeNuDThdcUTYRUfd6ryE9EeRGZdn8zjHXpvFHEzLJpVKguzCw", + message: str = "Account and application already paired", + ): + return {"data": {"accountId": account_id}, **cls.error(205, message)} + + @classmethod + def pair_error_206_token_expired(cls, message: str = "Token not found or expired"): + return cls.error(206, message) + + @classmethod + def status( + cls, + status: Literal["on"] | Literal["off"], + operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", + ): + return {"data": {"operations": {operation_id: {"status": status}}}} + + @classmethod + def status_on( + cls, + operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", + ): + return cls.status("on", operation_id) + + @classmethod + def status_on_two_factor( + cls, operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", token: str = "WsKJdE" + ): + return { + "data": { + "operations": { + operation_id: { + "status": "on", + "two_factor": {"token": token, "generated": 1740477452824}, + } + } + } + } + + @classmethod + def status_off( + cls, + operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", + ): + return cls.status("off", operation_id) + + @classmethod + def history(cls): + return { + "data": { + "aD2Gm8s9b9c9CcNEGiWJ": { + "status": "on", + "pairedOn": 1740477094584, + "statusLastModified": 1740480074608, + "autoclose": 0, + "two_factor": "DISABLED", + "name": "Latch OIDC (develop)", + "description": "", + "imageURL": "https://latchstorage.blob.core.windows.net/pro-custom-images/avatar14.jpg", + "contactPhone": "", + "contactEmail": "", + "lock_on_request": "DISABLED", + "operations": { + "JtTnn9kEGLuKafa8w7Yr": { + "name": "test_12", + "status": "on", + "two_factor": "DISABLED", + "lock_on_request": "DISABLED", + "operations": {}, + }, + "FYNJHcBMBxTBRaLACYMU": { + "name": "test_23", + "status": "on", + "two_factor": "off", + "lock_on_request": "off", + "operations": {}, + }, + "ieypEgnFckmp2iHXcaXn": { + "name": "test_34", + "status": "off", + "two_factor": "MANDATORY", + "lock_on_request": "MANDATORY", + "operations": {}, + }, + }, + }, + "lastSeen": 1740480083647, + "clientVersion": [{"platform": "Android", "app": "25.1.0"}], + "count": 417, + "history": [ + { + "t": 1732539300038, + "action": "USER_UPDATE", + "what": "status", + "value": "off", + "was": "on", + "name": "Latch OIDC (develop)", + "userAgent": "okhttp/4.12.0", + "ip": "79.153.112.254", + }, + { + "t": 1738619666126, + "action": "USER_UPDATE", + "what": "status", + "value": "off", + "was": "on", + "name": "Latch OIDC (develop)", + "userAgent": "okhttp/4.12.0", + "ip": "176.83.78.87", + }, + { + "t": 1738619666153, + "action": "USER_UPDATE", + "what": "statusLastModified", + "value": "1738619666116", + "was": None, + "name": "Latch OIDC (develop)", + "userAgent": "okhttp/4.12.0", + "ip": "176.83.78.87", + }, + ], + } + } diff --git a/tests/syncio/__init__.py b/tests/syncio/__init__.py new file mode 100644 index 0000000..76d921a --- /dev/null +++ b/tests/syncio/__init__.py @@ -0,0 +1,18 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" diff --git a/tests/syncio/test_base.py b/tests/syncio/test_base.py new file mode 100644 index 0000000..db48b47 --- /dev/null +++ b/tests/syncio/test_base.py @@ -0,0 +1,95 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from unittest import TestCase +from unittest.mock import Mock + +from latch_sdk.exceptions import ApplicationAlreadyPaired, TokenNotFound +from latch_sdk.response import LatchResponse +from latch_sdk.syncio.base import BaseLatch, LatchSDK + +from ..factory import ResponseFactory + + +class LatchSDKTestCase(TestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + def setUp(self) -> None: + self.core = Mock(BaseLatch) + self.latch_sdk = LatchSDK(self.core) + + return super().setUp() + + def test_pair(self): + self.core.pair.return_value = LatchResponse( + ResponseFactory.pair("test_account") + ) + self.assertEqual(self.latch_sdk.pair("terwrw"), "test_account") + + def test_pair_error_206_token_expired(self): + self.core.pair.return_value = LatchResponse( + ResponseFactory.pair_error_206_token_expired() + ) + + with self.assertRaises(TokenNotFound): + self.latch_sdk.pair("terwrw") + + def test_pair_error_205_already_paired(self): + self.core.pair.return_value = LatchResponse( + ResponseFactory.pair_error_205_already_paired("test_account") + ) + + with self.assertRaises(ApplicationAlreadyPaired) as ex: + self.latch_sdk.pair("terwrw") + + self.assertEqual(ex.exception.account_id, "test_account") # type: ignore + + """ + pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] + pair_with_id = wrap_method( + response_pair, + BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] + ) + + unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] + + status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] + + operation_status = wrap_method( + response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] + ) + + lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] + unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] + + create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] + update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] + delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] + get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] + + get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] + instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] + create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] + update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] + delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] + + history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] + + """ diff --git a/tests/syncio/test_httpx.py b/tests/syncio/test_httpx.py new file mode 100644 index 0000000..6b26977 --- /dev/null +++ b/tests/syncio/test_httpx.py @@ -0,0 +1,196 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from unittest import TestCase +from unittest.mock import ANY, Mock, patch + +from latch_sdk.syncio.httpx import Latch + + +class LatchTestCase(TestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + @patch("latch_sdk.syncio.httpx.Client") + def test_http_get( + self, + client_mock: Mock, + ): + client_mock.return_value = client_mock + client_mock.__enter__.return_value = client_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + client_mock.assert_called() + client_mock.__enter__.assert_called_once() + client_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.syncio.httpx.Client") + def test_http_post( + self, + client_mock: Mock, + ): + client_mock.return_value = client_mock + client_mock.__enter__.return_value = client_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + client_mock.assert_called() + client_mock.__enter__.assert_called_once() + client_mock.request.assert_called_once_with( + "POST", + "http://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.syncio.httpx.Client") + def test_https_get( + self, + client_mock: Mock, + ): + client_mock.return_value = client_mock + client_mock.__enter__.return_value = client_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + latch.status("account_id_3454657656454") + + client_mock.assert_called() + client_mock.__enter__.assert_called_once() + client_mock.request.assert_called_once_with( + "GET", + "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.syncio.httpx.Client") + def test_https_post( + self, + client_mock: Mock, + ): + client_mock.return_value = client_mock + client_mock.__enter__.return_value = client_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + client_mock.assert_called() + client_mock.__enter__.assert_called_once() + client_mock.request.assert_called_once_with( + "POST", + "https://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.syncio.httpx.Client") + def test_proxy_http_get( + self, + client_mock: Mock, + ): + client_mock.return_value = client_mock + client_mock.__enter__.return_value = client_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com:8443" + + client_mock.assert_called_with(proxy="http://proxy.bar.com:8443") + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + client_mock.assert_called() + client_mock.__enter__.assert_called_once() + client_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.syncio.httpx.Client") + def test_proxy_no_port_http_get( + self, + client_mock: Mock, + ): + client_mock.return_value = client_mock + client_mock.__enter__.return_value = client_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com" + + client_mock.assert_called_with(proxy="http://proxy.bar.com") + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + client_mock.assert_called() + client_mock.__enter__.assert_called_once() + client_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) diff --git a/tests/syncio/test_pure.py b/tests/syncio/test_pure.py new file mode 100644 index 0000000..d515fab --- /dev/null +++ b/tests/syncio/test_pure.py @@ -0,0 +1,229 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from unittest import TestCase +from unittest.mock import ANY, Mock, patch + +from latch_sdk.syncio.pure import Latch + + +class LatchTestCase(TestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + @patch("http.client.HTTPConnection") + @patch("http.client.HTTPSConnection") + def test_http_get( + self, + https_conn_mock: Mock, + http_conn_mock: Mock, + ): + http_conn_mock.return_value = http_conn_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + http_conn_mock.assert_called_once_with(latch.host, latch.port) + http_conn_mock.request.assert_called_once_with( + "GET", + "/api/1.0/status/account_id_3454657656454", + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + https_conn_mock.assert_not_called() + + @patch("http.client.HTTPConnection") + @patch("http.client.HTTPSConnection") + def test_http_post( + self, + https_conn_mock: Mock, + http_conn_mock: Mock, + ): + http_conn_mock.return_value = http_conn_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + http_conn_mock.assert_called_once_with(latch.host, latch.port) + http_conn_mock.request.assert_called_once_with( + "POST", + "/api/1.0/pair/pinnnn", + "wallet=0x354tryhnghgr3&signature=0xwerghfgnfegwf", + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + https_conn_mock.assert_not_called() + + @patch("http.client.HTTPConnection") + @patch("http.client.HTTPSConnection") + def test_https_get( + self, + https_conn_mock: Mock, + http_conn_mock: Mock, + ): + https_conn_mock.return_value = https_conn_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + latch.status("account_id_3454657656454") + + https_conn_mock.assert_called_once_with(latch.host, latch.port) + https_conn_mock.request.assert_called_once_with( + "GET", + "/api/1.0/status/account_id_3454657656454", + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + http_conn_mock.assert_not_called() + + @patch("http.client.HTTPConnection") + @patch("http.client.HTTPSConnection") + def test_https_post( + self, + https_conn_mock: Mock, + http_conn_mock: Mock, + ): + https_conn_mock.return_value = https_conn_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + https_conn_mock.assert_called_once_with(latch.host, latch.port) + https_conn_mock.request.assert_called_once_with( + "POST", + "/api/1.0/pair/pinnnn", + "wallet=0x354tryhnghgr3&signature=0xwerghfgnfegwf", + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + http_conn_mock.assert_not_called() + + @patch("http.client.HTTPConnection") + @patch("http.client.HTTPSConnection") + def test_proxy_http_get( + self, + https_conn_mock: Mock, + http_conn_mock: Mock, + ): + https_conn_mock.return_value = https_conn_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com:8443" + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + https_conn_mock.assert_called_once_with(latch.proxy_host, latch.proxy_port) + https_conn_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + http_conn_mock.assert_not_called() + + @patch("http.client.HTTPConnection") + @patch("http.client.HTTPSConnection") + def test_proxy_http_port_get( + self, + https_conn_mock: Mock, + http_conn_mock: Mock, + ): + https_conn_mock.return_value = https_conn_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com:8443" + latch.host = "http://foo.bar.com:8080" + + latch.status("account_id_3454657656454") + + https_conn_mock.assert_called_once_with(latch.proxy_host, latch.proxy_port) + https_conn_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com:8080/api/1.0/status/account_id_3454657656454", + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + http_conn_mock.assert_not_called() + + @patch("http.client.HTTPConnection") + @patch("http.client.HTTPSConnection") + def test_proxy_https_get( + self, + https_conn_mock: Mock, + http_conn_mock: Mock, + ): + https_conn_mock.return_value = https_conn_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com:8443" + latch.host = "https://foo.bar.com" + + latch.status("account_id_3454657656454") + + https_conn_mock.assert_called_once_with(latch.proxy_host, latch.proxy_port) + https_conn_mock.set_tunnel.assert_called_once_with(latch.host, latch.port) + https_conn_mock.request.assert_called_once_with( + "GET", + "/api/1.0/status/account_id_3454657656454", + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + http_conn_mock.assert_not_called() diff --git a/tests/syncio/test_requests.py b/tests/syncio/test_requests.py new file mode 100644 index 0000000..d554d39 --- /dev/null +++ b/tests/syncio/test_requests.py @@ -0,0 +1,206 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from unittest import TestCase +from unittest.mock import ANY, Mock, patch + +from latch_sdk.syncio.requests import Latch + + +class LatchTestCase(TestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + @patch("latch_sdk.syncio.requests.Session") + def test_http_get( + self, + session_mock: Mock, + ): + session_mock.return_value = session_mock + session_mock.__enter__.return_value = session_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + session_mock.assert_called() + session_mock.__enter__.assert_called_once() + session_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.syncio.requests.Session") + def test_http_post( + self, + session_mock: Mock, + ): + session_mock.return_value = session_mock + session_mock.__enter__.return_value = session_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "http://foo.bar.com" + + latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + session_mock.assert_called() + session_mock.__enter__.assert_called_once() + session_mock.request.assert_called_once_with( + "POST", + "http://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.syncio.requests.Session") + def test_https_get( + self, + session_mock: Mock, + ): + session_mock.return_value = session_mock + session_mock.__enter__.return_value = session_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + latch.status("account_id_3454657656454") + + session_mock.assert_called() + session_mock.__enter__.assert_called_once() + session_mock.request.assert_called_once_with( + "GET", + "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.syncio.requests.Session") + def test_https_post( + self, + session_mock: Mock, + ): + session_mock.return_value = session_mock + session_mock.__enter__.return_value = session_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.host = "https://foo.bar.com" + + latch.pair( + "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" + ) + + session_mock.assert_called() + session_mock.__enter__.assert_called_once() + session_mock.request.assert_called_once_with( + "POST", + "https://foo.bar.com/api/1.0/pair/pinnnn", + data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + "Content-type": "application/x-www-form-urlencoded", + }, + ) + + @patch("latch_sdk.syncio.requests.Session") + def test_proxy_http_get( + self, + session_mock: Mock, + ): + session_mock.return_value = session_mock + session_mock.__enter__.return_value = session_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com:8443" + + session_mock.proxies.update.assert_called_once_with( + { + "http": "https://proxy.bar.com:8443", + "https": "https://proxy.bar.com:8443", + } + ) + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + session_mock.assert_called() + session_mock.__enter__.assert_called_once() + session_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) + + @patch("latch_sdk.syncio.requests.Session") + def test_proxy_no_port_http_get( + self, + session_mock: Mock, + ): + session_mock.return_value = session_mock + session_mock.__enter__.return_value = session_mock + + latch = Latch(self.API_ID, self.SECRET) + + latch.proxy_host = "proxy.bar.com" + + session_mock.proxies.update.assert_called_once_with( + { + "http": "https://proxy.bar.com", + "https": "https://proxy.bar.com", + } + ) + latch.host = "http://foo.bar.com" + + latch.status("account_id_3454657656454") + + session_mock.assert_called() + session_mock.__enter__.assert_called_once() + session_mock.request.assert_called_once_with( + "GET", + "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + data=None, + headers={ + "Authorization": ANY, + "X-11Paths-Date": ANY, + }, + ) diff --git a/tests/test.py b/tests/test.py deleted file mode 100644 index da2e958..0000000 --- a/tests/test.py +++ /dev/null @@ -1,88 +0,0 @@ -""" - This library offers an API to use LatchAuth in a python environment. - Copyright (C) 2023 Telefonica Digital - - This library is free software you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" -import unittest -from src import latch -from src.latchuser import LatchUser -from dotenv import load_dotenv, dotenv_values - -load_dotenv() -ENV_VARS = dotenv_values("./.env") - - -# .env file has this vars to test de library - -class MyTestCase(unittest.TestCase): - - def setUp(self): - self.account_id = ENV_VARS["account_id"] - self.app_id = ENV_VARS["app_id"] - self.secret_id = ENV_VARS["secret_id"] - self.user_id = ENV_VARS["user_id"] - self.user_secret = ENV_VARS["user_secret"] - self.api = latch.Latch(self.app_id, self.secret_id) - - def test_app_latch_pair_invalid_token(self): - response = self.api.pair("fP9zpf") - assert response.error.get_message() == "Token not found or expired" - assert response.error.get_code() == 206 - - def test_crud_operation(self): - response = self.api.create_operation(self.app_id, "operation_test_1", "DISABLED", "DISABLED") - operation_id = response.get_data()['operationId'] - response = self.api.update_operation(operation_id, "operation_test_1_v2", "MANDATORY", "MANDATORY") - assert response.get_data() == "" and response.get_error() == "" - response = self.api.create_operation(operation_id, "sub_operation_test_1", "DISABLED", "DISABLED") - sub_operation_id = response.get_data()['operationId'] - response = self.api.get_operations(operation_id) - assert response.get_data()['operations'][sub_operation_id]['name'] == "sub_operation_test_1" - response = self.api.delete_operation(sub_operation_id) - assert response.get_data() == "" and response.get_error() == "" - response = self.api.delete_operation(operation_id) - assert response.get_data() == "" and response.get_error() == "" - - def test_crud_instance(self): - response = self.api.create_operation(self.app_id, "operation_test_1", "DISABLED", "DISABLED") - operation_id = response.get_data()['operationId'] - response = self.api.create_instance("Instance1", self.account_id, operation_id) - instance_id = list(response.get_data()['instances'].keys())[0] - assert list(response.get_data()['instances'].values())[0] == "Instance1" - self.api.update_instance(instance_id, self.account_id, operation_id, "instance1_v2", "DISABLED", - "DISABLED") - response = self.api.get_instances(self.account_id, operation_id) - assert response.get_data()[instance_id]['two_factor'] == "DISABLED" - - response = self.api.instance_status(instance_id, self.account_id, operation_id, False, False) - assert response.get_data()['operations'][instance_id]['status'] == 'on' - self.api.delete_instance(instance_id, self.account_id) - self.api.delete_operation(operation_id) - - def test_latch_user(self): - latch_user = LatchUser(self.user_id, self.user_secret) - response = latch_user.create_application('app2', "DISABLED", "DISABLED", "60000000", "mail@mailfake.com") - application_id = response.get_data()['applicationId'] - response = latch_user.get_applications() - assert application_id in response.get_data()['operations'].keys() - - def test_get_status(self): - response = self.api.status(self.account_id) - assert response.get_data()['operations'][self.app_id] == {'status': 'on'} - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..a7686b4 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,49 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from unittest import TestCase + +from latch_sdk.exceptions import ( + AccountNotPaired, + BaseLatchException, + InvalidCredentials, + LatchError, + UnauthorizedScope, + UnauthorizedUser, +) + + +class ExceptionsTestCase(TestCase): + def test_instance_by_code(self) -> None: + codes: dict[int, type[BaseLatchException]] = { + InvalidCredentials.CODE: InvalidCredentials, + UnauthorizedUser.CODE: UnauthorizedUser, + UnauthorizedScope.CODE: UnauthorizedScope, + AccountNotPaired.CODE: AccountNotPaired, + } + + for code, klass in codes.items(): + ex = BaseLatchException(code, f"Test exception {code}") + + self.assertIsInstance(ex, klass) + + def test_unknown_code(self) -> None: + ex = BaseLatchException(50001, f"Test exception {50001}") + + self.assertIsInstance(ex, LatchError) diff --git a/tests/test_sansio.py b/tests/test_sansio.py new file mode 100644 index 0000000..52437d9 --- /dev/null +++ b/tests/test_sansio.py @@ -0,0 +1,2492 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +import secrets +from datetime import datetime, timedelta, timezone +from textwrap import dedent +from typing import Callable, Mapping +from unittest import TestCase +from unittest.mock import Mock + +from latch_sdk.exceptions import ( + ApplicationAlreadyPaired, + ApplicationNotFound, + InvalidCredentials, + MaxActionsExceed, + TokenNotFound, +) +from latch_sdk.models import ExtraFeature, HistoryResponse, Instance, Operation, Status +from latch_sdk.response import LatchResponse +from latch_sdk.sansio import ( + LatchSansIO, + check_error, + response_add_instance, + response_history, + response_instance_list, + response_no_error, + response_operation, + response_operation_list, + response_pair, + response_status, +) +from latch_sdk.utils import sign_data + +from .factory import ResponseFactory + + +class CheckErrorTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse({"data": {}}) + + check_error(resp) + + def test_with_error(self) -> None: + resp = LatchResponse( + {"error": {"code": InvalidCredentials.CODE, "message": "test message"}} + ) + + with self.assertRaises(InvalidCredentials) as ex: + check_error(resp) + + self.assertEqual(ex.exception.message, "test message") + + +class ResponsePairTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse(ResponseFactory.pair("test_account")) + + account_id = response_pair(resp) + + self.assertEqual(account_id, "test_account") + + def test_with_error(self) -> None: + resp = LatchResponse( + ResponseFactory.pair_error_206_token_expired("test message") + ) + + with self.assertRaises(TokenNotFound) as ex: + response_pair(resp) + + self.assertEqual(ex.exception.message, "test message") + + def test_with_error_already_paired(self) -> None: + resp = LatchResponse( + ResponseFactory.pair_error_205_already_paired( + "test_account", "test message" + ) + ) + + with self.assertRaises(ApplicationAlreadyPaired) as ex: + response_pair(resp) + + self.assertEqual(ex.exception.message, "test message") + self.assertEqual(ex.exception.account_id, "test_account") + + +class ResponseNoErrorTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse(ResponseFactory.no_data()) + + self.assertTrue(response_no_error(resp)) + + def test_with_error(self) -> None: + resp = LatchResponse( + ResponseFactory.error(ApplicationNotFound.CODE, "test message") + ) + + with self.assertRaises(ApplicationNotFound) as ex: + response_no_error(resp) + + self.assertEqual(ex.exception.message, "test message") + + +class ResponseStatusTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse(ResponseFactory.status_on("account_id")) + + status = response_status(resp) + + self.assertIsInstance(status, Status) + self.assertEqual(status.operation_id, "account_id") + self.assertTrue(status.status) + + def test_require_otp(self) -> None: + resp = LatchResponse( + ResponseFactory.status_on_two_factor("account_id", "123456") + ) + + status = response_status(resp) + + self.assertIsInstance(status, Status) + self.assertEqual(status.operation_id, "account_id") + self.assertTrue(status.status) + self.assertIsNotNone(status.two_factor) + self.assertEqual(status.two_factor.token, "123456") # type: ignore + + def test_with_error(self) -> None: + resp = LatchResponse( + {"error": {"code": MaxActionsExceed.CODE, "message": "test message"}} + ) + + with self.assertRaises(MaxActionsExceed) as ex: + response_status(resp) + + self.assertEqual(ex.exception.message, "test message") + + +class ResponseOperationTestCase(TestCase): + def test_no_error_operation_key(self) -> None: + resp = LatchResponse( + {"data": {"operations": {"operation_id": {"name": "Operation Test"}}}} + ) + + operation = response_operation(resp) + + self.assertIsInstance(operation, Operation) + self.assertEqual(operation.operation_id, "operation_id") + self.assertEqual(operation.name, "Operation Test") + + def test_no_error_no_key(self) -> None: + resp = LatchResponse({"data": {"operation_id": {"name": "Operation Test"}}}) + + operation = response_operation(resp) + + self.assertIsInstance(operation, Operation) + self.assertEqual(operation.operation_id, "operation_id") + self.assertEqual(operation.name, "Operation Test") + + def test_with_error(self) -> None: + resp = LatchResponse( + {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} + ) + + with self.assertRaises(ApplicationNotFound) as ex: + response_operation(resp) + + self.assertEqual(ex.exception.message, "test message") + + +class ResponseOperationListTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse( + { + "data": { + "operations": { + "operation_id_1": {"name": "Operation Test 1"}, + "operation_id_2": {"name": "Operation Test 2"}, + } + } + } + ) + + operation_list = iter(response_operation_list(resp)) + + operation = next(operation_list) + self.assertIsInstance(operation, Operation) + self.assertEqual(operation.operation_id, "operation_id_1") + self.assertEqual(operation.name, "Operation Test 1") + + operation = next(operation_list) + self.assertIsInstance(operation, Operation) + self.assertEqual(operation.operation_id, "operation_id_2") + self.assertEqual(operation.name, "Operation Test 2") + + with self.assertRaises(StopIteration): + next(operation_list) + + def test_no_error_empty(self) -> None: + resp = LatchResponse({"data": {}}) + + operation_list = iter(response_operation_list(resp)) + + with self.assertRaises(StopIteration): + next(operation_list) + + def test_with_error(self) -> None: + resp = LatchResponse( + {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} + ) + + with self.assertRaises(ApplicationNotFound) as ex: + response_operation_list(resp) + + self.assertEqual(ex.exception.message, "test message") + + +class ResponseInstanceListTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse( + { + "data": { + "instance_id_1": {"name": "Instance Test 1"}, + "instance_id_2": {"name": "Instance Test 2"}, + } + } + ) + + instance_list = iter(response_instance_list(resp)) + + instance = next(instance_list) + self.assertIsInstance(instance, Instance) + self.assertEqual(instance.instance_id, "instance_id_1") + self.assertEqual(instance.name, "Instance Test 1") + + instance = next(instance_list) + self.assertIsInstance(instance, Instance) + self.assertEqual(instance.instance_id, "instance_id_2") + self.assertEqual(instance.name, "Instance Test 2") + + with self.assertRaises(StopIteration): + next(instance_list) + + def test_no_error_empty(self) -> None: + resp = LatchResponse({"data": {}}) + + operation_list = iter(response_instance_list(resp)) + + with self.assertRaises(StopIteration): + next(operation_list) + + def test_with_error(self) -> None: + resp = LatchResponse( + {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} + ) + + with self.assertRaises(ApplicationNotFound) as ex: + response_instance_list(resp) + + self.assertEqual(ex.exception.message, "test message") + + +class ResponseAddInstanceTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse({"data": {"instance_id_1": "Instance Test 1"}}) + + self.assertEqual(response_add_instance(resp), "instance_id_1") + + def test_with_error(self) -> None: + resp = LatchResponse( + {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} + ) + + with self.assertRaises(ApplicationNotFound) as ex: + response_add_instance(resp) + + self.assertEqual(ex.exception.message, "test message") + + +class ResponseHistoryTestCase(TestCase): + def test_no_error(self) -> None: + resp = LatchResponse(ResponseFactory.history()) + + data = response_history(resp) + + self.assertIsInstance(data, HistoryResponse) + + +""" +TODO: Test history response + +def response_history(resp: LatchResponse) -> HistoryResponse: + check_error(resp) + + assert resp.data is not None, "No error or data" + + return HistoryResponse.build_from_dict(resp.data) + + +""" + + +class LatchClassMethodsTestCase(TestCase): + def test_build_paths(self): + self.assertEqual( + LatchSansIO.build_paths("v1"), + { + "application": "/api/v1/application", + "check_status": "/api/v1/status", + "history": "/api/v1/history", + "instance": "/api/v1/instance", + "lock": "/api/v1/lock", + "operation": "/api/v1/operation", + "pair": "/api/v1/pair", + "pair_with_id": "/api/v1/pairWithId", + "subscription": "/api/v1/subscription", + "unlock": "/api/v1/unlock", + "unpair": "/api/v1/unpair", + }, + ) + + self.assertEqual( + LatchSansIO.build_paths("p2"), + { + "application": "/api/p2/application", + "check_status": "/api/p2/status", + "history": "/api/p2/history", + "instance": "/api/p2/instance", + "lock": "/api/p2/lock", + "operation": "/api/p2/operation", + "pair": "/api/p2/pair", + "pair_with_id": "/api/p2/pairWithId", + "subscription": "/api/p2/subscription", + "unlock": "/api/p2/unlock", + "unpair": "/api/p2/unpair", + }, + ) + + def test_get_part_from_header(self): + header = "part0 part1 part2" + self.assertEqual(LatchSansIO.get_part_from_header(0, header), "part0") + self.assertEqual(LatchSansIO.get_part_from_header(1, header), "part1") + self.assertEqual(LatchSansIO.get_part_from_header(2, header), "part2") + + def test_get_part_from_header_out_of_bounds(self): + with self.assertRaises(IndexError): + LatchSansIO.get_part_from_header(3, "part0 part1 part2") + + def test_get_part_from_header_empty(self): + with self.assertRaises(IndexError): + LatchSansIO.get_part_from_header(0, "") + + with self.assertRaises(IndexError): + LatchSansIO.get_part_from_header(0, " ") + + def test_get_auth_method_from_header(self): + self.assertEqual( + LatchSansIO.get_auth_method_from_header("part0 part1 part2"), "part0" + ) + + def test_get_auth_method_from_header_out_of_bounds(self): + with self.assertRaises(IndexError): + LatchSansIO.get_auth_method_from_header("") + + def test_get_app_id_from_header(self): + self.assertEqual( + LatchSansIO.get_app_id_from_header("part0 part1 part2"), "part1" + ) + + def test_get_app_id_from_header_out_of_bounds(self): + with self.assertRaises(IndexError): + LatchSansIO.get_app_id_from_header("part0") + + def test_get_signature_from_header(self): + self.assertEqual( + LatchSansIO.get_signature_from_header("part0 part1 part2"), "part2" + ) + + def test_get_signature_from_header_out_of_bounds(self): + with self.assertRaises(IndexError): + LatchSansIO.get_signature_from_header("part0 part1") + + def test_get_current_UTC(self): + self.assertEqual( + LatchSansIO.get_current_UTC(), + datetime.now(tz=timezone.utc).strftime(LatchSansIO.UTC_STRING_FORMAT), + ) + + def test_get_serialized_headers_empty(self): + self.assertEqual("", LatchSansIO.get_serialized_headers({})) + + def test_get_serialized_headers_none(self): + self.assertEqual("", LatchSansIO.get_serialized_headers(None)) + + def test_get_serialized_headers_fail_no_valid_header(self): + with self.assertRaises(ValueError): + LatchSansIO.get_serialized_headers( + { + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header": "bbb", + "no-valid-header": "aaa", + } + ) + + def test_get_serialized_headers_fail_no_valid_header_alone(self): + with self.assertRaises(ValueError): + LatchSansIO.get_serialized_headers( + { + "no-valid-header": "aaa", + } + ) + + def test_get_serialized_headers(self): + self.assertEqual( + "x-11paths-valid-header-1:1 x-11paths-valid-header-2:2 x-11paths-valid-header-3:3", + LatchSansIO.get_serialized_headers( + { + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-3": "3", + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-1": "1", + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-2": "2", + } + ), + ) + + def test_get_serialized_params_empty(self): + self.assertEqual("", LatchSansIO.get_serialized_params({})) + + def test_get_serialized_params(self): + self.assertEqual( + "param_1=1¶m_2=2¶m_3=3", + LatchSansIO.get_serialized_params( + { + "param_3": "3", + "param_1": "1", + "param_2": "2", + } + ), + ) + + def test_build_data_to_sign(self): + self.assertEqual( + dedent( + """ + POST + 2025-02-24 10:32:23 + x-11paths-valid-header-1:1 x-11paths-valid-header-2:2 x-11paths-valid-header-3:3 + /path/to/op?query_param_1=1 + param_1=1¶m_2=2¶m_3=3 + """ + ).strip(), + LatchSansIO.build_data_to_sign( + "POST", + "2025-02-24 10:32:23", + "/path/to/op?query_param_1=1", + { + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-2": "2", + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-3": "3", + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-1": "1", + }, + { + "param_3": "3", + "param_1": "1", + "param_2": "2", + }, + ), + ) + + def test_build_data_to_sign_no_headers(self): + self.assertEqual( + dedent( + """ + POST + 2025-02-24 10:32:23 + + /path/to/op?query_param_1=1 + param_1=1¶m_2=2¶m_3=3 + """ + ).strip(), + LatchSansIO.build_data_to_sign( + "POST", + "2025-02-24 10:32:23", + "/path/to/op?query_param_1=1", + None, + { + "param_3": "3", + "param_1": "1", + "param_2": "2", + }, + ), + ) + + def test_build_data_to_sign_no_params(self): + self.assertEqual( + dedent( + """ + GET + 2025-02-24 10:32:23 + x-11paths-valid-header-1:1 x-11paths-valid-header-2:2 x-11paths-valid-header-3:3 + /path/to/op?query_param_1=1 + + """ + ).strip(), + LatchSansIO.build_data_to_sign( + "GET", + "2025-02-24 10:32:23", + "/path/to/op?query_param_1=1", + { + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-2": "2", + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-3": "3", + f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-1": "1", + }, + ), + ) + + def test_build_data_to_sign_no_header_no_params(self): + self.assertEqual( + dedent( + """ + GET + 2025-02-24 10:32:23 + + /path/to/op?query_param_1=1 + + """ + ).strip(), + LatchSansIO.build_data_to_sign( + "GET", + "2025-02-24 10:32:23", + "/path/to/op?query_param_1=1", + ), + ) + + +class LatchTesting(LatchSansIO[LatchResponse]): + def __init__( + self, + *args, + http_callback: Callable[ + [ + str, + str, + Mapping[str, str], + Mapping[str, str] | None, + ], + LatchResponse, + ], + **kwargs, + ) -> None: + self.reconfigure_mock = Mock() + + super().__init__(*args, **kwargs) + + self._http_cb = http_callback + + def _http( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + return self._http_cb(method, path, headers, params) + + def _reconfigure_session(self): + self.reconfigure_mock() + return super()._reconfigure_session() + + +class LatchTestCase(TestCase): + API_ID = "65y4ujtngbvfasesurikjfhdgxr" + SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" + + def assert_request( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ): + self.assertIn(LatchTesting.DATE_HEADER_NAME, headers) + self.assertIn(LatchTesting.AUTHORIZATION_HEADER_NAME, headers) + auth_parts = headers[LatchTesting.AUTHORIZATION_HEADER_NAME].split( + LatchTesting.AUTHORIZATION_HEADER_FIELD_SEPARATOR, 2 + ) + self.assertEqual(len(auth_parts), 3) + self.assertEqual(auth_parts[0], LatchTesting.AUTHORIZATION_METHOD) + self.assertEqual(auth_parts[1], self.API_ID) + self.assertEqual( + auth_parts[2], + sign_data( + self.SECRET.encode(), + LatchTesting.build_data_to_sign( + method, + headers[LatchTesting.DATE_HEADER_NAME], + path, + { + k: v + for k, v in headers.items() + if k.startswith(LatchTesting.X_11PATHS_HEADER_PREFIX) + and k != LatchTesting.DATE_HEADER_NAME + }, + params, + ).encode(), + ).decode(), + ) + + def test_pair_with_id(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, "/api/1.0/pairWithId/eregerdscvrtrd") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {"accountId": "latch_account_id"}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.pair_with_id(account_id) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore + + http_cb.assert_called_once() + + def test_pair_with_id_web3(self) -> None: + account_id = "eregerdscvrtrd" + web3_account = f"0x{secrets.token_hex(20)}" + web3_signature = f"0x{secrets.token_hex(20)}" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual(path, "/api/1.0/pairWithId/eregerdscvrtrd") + self.assert_request(method, path, headers, params) + + self.assertEqual( + params, + { + "wallet": web3_account, + "signature": web3_signature, + }, + ) + + return LatchResponse({"data": {"accountId": "latch_account_id"}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.pair_with_id( + account_id, web3_account=web3_account, web3_signature=web3_signature + ) + + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore + + http_cb.assert_called_once() + + def test_pair(self) -> None: + pin = "3edcvb" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/pair/{pin}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {"accountId": "latch_account_id"}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.pair(pin) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore + + http_cb.assert_called_once() + + def test_pair_already_paired(self) -> None: + pin = "3edcvb" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/pair/{pin}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse( + ResponseFactory.pair_error_205_already_paired("latch_account_id") + ) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.pair(pin) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore + + self.assertIsNotNone(resp.error) + self.assertEqual(resp.error.code, ApplicationAlreadyPaired.CODE) # type: ignore + + http_cb.assert_called_once() + + def test_pair_web3(self) -> None: + pin = "3edcvb" + web3_account = f"0x{secrets.token_hex(20)}" + web3_signature = f"0x{secrets.token_hex(20)}" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual(path, f"/api/1.0/pair/{pin}") + self.assert_request(method, path, headers, params) + + self.assertEqual( + params, + { + "wallet": web3_account, + "signature": web3_signature, + }, + ) + + return LatchResponse({"data": {"accountId": "latch_account_id"}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.pair(pin, web3_account=web3_account, web3_signature=web3_signature) + + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore + + http_cb.assert_called_once() + + def test_status(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/status/{account_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.status(account_id) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_status_nootp(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/status/{account_id}/nootp") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.status(account_id, nootp=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_status_silent(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/status/{account_id}/silent") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.status(account_id, silent=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_status_nootp_and_silent(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/status/{account_id}/nootp/silent") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.status(account_id, nootp=True, silent=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_operation_status(self) -> None: + account_id = "eregerdscvrtrd" + operation_id = "ggggrer4tgfvbd6t5y4t" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/status/{account_id}/op/{operation_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.operation_status(account_id, operation_id) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_operation_status_nootp(self) -> None: + account_id = "eregerdscvrtrd" + operation_id = "ggggrer4tgfvbd6t5y4t" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, f"/api/1.0/status/{account_id}/op/{operation_id}/nootp" + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.operation_status(account_id, operation_id, nootp=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_operation_status_silent(self) -> None: + account_id = "eregerdscvrtrd" + operation_id = "ggggrer4tgfvbd6t5y4t" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, f"/api/1.0/status/{account_id}/op/{operation_id}/silent" + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.operation_status(account_id, operation_id, silent=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_operation_status_nootp_and_silent(self) -> None: + account_id = "eregerdscvrtrd" + operation_id = "ggggrer4tgfvbd6t5y4t" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, f"/api/1.0/status/{account_id}/op/{operation_id}/nootp/silent" + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.operation_status(account_id, operation_id, nootp=True, silent=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_unpair(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/unpair/{account_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.unpair(account_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_lock(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual(path, f"/api/1.0/lock/{account_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.lock(account_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_lock_operation(self) -> None: + account_id = "eregerdscvrtrd" + operation_id = "terthbdvcs4g5hxt" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual(path, f"/api/1.0/lock/{account_id}/op/{operation_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.lock(account_id, operation_id=operation_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_unlock(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual(path, f"/api/1.0/unlock/{account_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.unlock(account_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_unlock_operation(self) -> None: + account_id = "eregerdscvrtrd" + operation_id = "terthbdvcs4g5hxt" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual(path, f"/api/1.0/unlock/{account_id}/op/{operation_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.unlock(account_id, operation_id=operation_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_history(self) -> None: + account_id = "eregerdscvrtrd" + to_dt = datetime.now(tz=timezone.utc) + from_dt = to_dt - timedelta(days=2) + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}/{round(to_dt.timestamp() * 1000)}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.history(account_id, from_dt=from_dt, to_dt=to_dt) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_history_no_from(self) -> None: + account_id = "eregerdscvrtrd" + to_dt = datetime.now(tz=timezone.utc) + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/history/{account_id}/0/{round(to_dt.timestamp() * 1000)}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.history(account_id, to_dt=to_dt) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_history_no_to(self) -> None: + account_id = "eregerdscvrtrd" + from_dt = datetime.now(tz=timezone.utc) - timedelta(days=2) + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}" + f"/{round(round(datetime.now(tz=timezone.utc).timestamp() * 1000))}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.history(account_id, from_dt=from_dt) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_history_no_from_no_to(self) -> None: + account_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/history/{account_id}/0/{round(datetime.now(tz=timezone.utc).timestamp() * 1000)}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.history(account_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_create_operation(self) -> None: + parent_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "PUT") + + self.assertEqual( + path, + "/api/1.0/operation", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + { + "parentId": parent_id, + "name": "new_op", + "two_factor": "DISABLED", + "lock_on_request": "DISABLED", + }, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.create_operation(parent_id, "new_op") + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_operation(self) -> None: + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + { + "name": "new_op", + }, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_operation(operation_id, "new_op") + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_operation_two_factor(self) -> None: + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + {"name": "new_op", "two_factor": ExtraFeature.MANDATORY.value}, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_operation( + operation_id, "new_op", two_factor=ExtraFeature.MANDATORY + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_operation_lock_on_request(self) -> None: + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + {"name": "new_op", "lock_on_request": ExtraFeature.OPT_IN.value}, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_operation( + operation_id, "new_op", lock_on_request=ExtraFeature.OPT_IN + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_operation_two_factor_and_lock_on_request(self) -> None: + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + { + "name": "new_op", + "two_factor": ExtraFeature.MANDATORY.value, + "lock_on_request": ExtraFeature.OPT_IN.value, + }, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_operation( + operation_id, + "new_op", + two_factor=ExtraFeature.MANDATORY, + lock_on_request=ExtraFeature.OPT_IN, + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_delete_operation(self) -> None: + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "DELETE") + + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone( + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.delete_operation( + operation_id, + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_get_operations(self) -> None: + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + "/api/1.0/operation", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone( + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.get_operations() + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_get_operations_children(self) -> None: + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone( + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.get_operations( + operation_id=operation_id, + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_get_instances(self) -> None: + account_id = "ryhtggfdwdffhgrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone( + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.get_instances(account_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_get_instances_operation(self) -> None: + account_id = "ryhtggfdwdffhgrd" + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/op/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone( + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.get_instances( + account_id, + operation_id=operation_id, + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_create_instance_operation(self) -> None: + account_id = "ryhtggfdwdffhgrd" + operation_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "PUT") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/op/{operation_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual(params, {"instances": "new_instance"}) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.create_instance( + "new_instance", + account_id, + operation_id=operation_id, + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_create_instance(self) -> None: + account_id = "ryhtggfdwdffhgrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "PUT") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual(params, {"instances": "new_instance"}) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.create_instance( + "new_instance", + account_id, + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_instance_status(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual(path, f"/api/1.0/status/{account_id}/i/{instance_id}") + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status(instance_id, account_id) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_instance_status_nootp(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, f"/api/1.0/status/{account_id}/i/{instance_id}/nootp" + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status(instance_id, account_id, nootp=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_instance_status_silent(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, f"/api/1.0/status/{account_id}/i/{instance_id}/silent" + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status(instance_id, account_id, silent=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_instance_status_nootp_and_silent(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, f"/api/1.0/status/{account_id}/i/{instance_id}/nootp/silent" + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status(instance_id, account_id, nootp=True, silent=True) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_instance_operation_status(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + operation_id = "t43yhtrbvecw4v5e" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}" + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status(instance_id, account_id, operation_id=operation_id) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_instance_operation_status_nootp(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + operation_id = "t43yhtrbvecw4v5e" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}/nootp", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status( + instance_id, account_id, operation_id=operation_id, nootp=True + ) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_instance_operation_status_silent(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + operation_id = "t43yhtrbvecw4v5e" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}/silent", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status( + instance_id, account_id, operation_id=operation_id, silent=True + ) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_instance_operation_status_nootp_and_silent(self) -> None: + account_id = "eregerdscvrtrd" + instance_id = "435465grebhy5t4" + operation_id = "t43yhtrbvecw4v5e" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}/nootp/silent", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return LatchResponse({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.instance_status( + instance_id, account_id, operation_id=operation_id, nootp=True, silent=True + ) + self.assertIsInstance(resp, LatchResponse) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + http_cb.assert_called_once() + + def test_update_instance(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/i/{instance_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + { + "name": "new_op", + }, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_instance(instance_id, account_id, name="new_op") + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_instance_two_factor(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/i/{instance_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + {"name": "new_op", "two_factor": ExtraFeature.MANDATORY.value}, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_instance( + instance_id, account_id, name="new_op", two_factor=ExtraFeature.MANDATORY + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_instance_lock_on_request(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/i/{instance_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + {"name": "new_op", "lock_on_request": ExtraFeature.OPT_IN.value}, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_instance( + instance_id, account_id, name="new_op", lock_on_request=ExtraFeature.OPT_IN + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_instance_two_factor_and_lock_on_request(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/i/{instance_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + { + "name": "new_op", + "two_factor": ExtraFeature.MANDATORY.value, + "lock_on_request": ExtraFeature.OPT_IN.value, + }, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_instance( + instance_id, + account_id, + name="new_op", + two_factor=ExtraFeature.MANDATORY, + lock_on_request=ExtraFeature.OPT_IN, + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_instance_operation(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + operation_id = "87uyjhgfe4rtg" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/op/{operation_id}/i/{instance_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + { + "name": "new_op", + }, + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.update_instance( + instance_id, account_id, operation_id=operation_id, name="new_op" + ) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_update_instance_fail(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ): + pass + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + with self.assertRaises(ValueError): + latch.update_instance(instance_id, account_id) + + http_cb.assert_not_called() + + def test_delete_instance(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "DELETE") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/i/{instance_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone( + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.delete_instance(instance_id, account_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_delete_instance_operation(self) -> None: + account_id = "3463453etgvd" + instance_id = "eregerdscvrtrd" + operation_id = "87uyjhgfe4rtg" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + self.assertEqual(method, "DELETE") + + self.assertEqual( + path, + f"/api/1.0/instance/{account_id}/op/{operation_id}/i/{instance_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone( + params, + ) + + return LatchResponse({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.delete_instance(instance_id, account_id, operation_id=operation_id) + self.assertIsInstance(resp, LatchResponse) + + http_cb.assert_called_once() + + def test_set_host(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + self.assertTrue(latch.is_https) + self.assertEqual(latch.port, 443) + + latch.host = "foo.bar.com" + + self.assertTrue(latch.is_https) + self.assertEqual(latch.port, 443) + self.assertEqual(latch.host, "foo.bar.com") + + latch.reconfigure_mock.assert_called() + + latch.reconfigure_mock.reset_mock() + + latch.host = "foo.bar.com" + latch.reconfigure_mock.assert_not_called() + + def test_set_host_port(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "foo.bar.com:8443" + + self.assertTrue(latch.is_https) + self.assertEqual(latch.port, 8443) + self.assertEqual(latch.host, "foo.bar.com") + + latch.host = "foo.bar.com:8443" + + latch.reconfigure_mock.assert_called() + latch.reconfigure_mock.reset_mock() + + latch.host = "foo.bar.com:8443" + latch.reconfigure_mock.assert_not_called() + + def test_set_host_url_http(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "http://foo.bar.com" + + self.assertFalse(latch.is_https) + self.assertEqual(latch.port, 80) + self.assertEqual(latch.host, "foo.bar.com") + + latch.reconfigure_mock.assert_called() + latch.reconfigure_mock.reset_mock() + + latch.host = "http://foo.bar.com" + latch.reconfigure_mock.assert_not_called() + + def test_set_host_url_http_port(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "http://foo.bar.com:8080" + + self.assertFalse(latch.is_https) + self.assertEqual(latch.port, 8080) + self.assertEqual(latch.host, "foo.bar.com") + + latch.reconfigure_mock.assert_called() + latch.reconfigure_mock.reset_mock() + + latch.host = "http://foo.bar.com:8080" + latch.reconfigure_mock.assert_not_called() + + def test_set_host_url_https(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "http://init.bar.com" + + self.assertFalse(latch.is_https) + self.assertEqual(latch.port, 80) + self.assertEqual(latch.host, "init.bar.com") + + latch.host = "https://foo.bar.com" + + self.assertTrue(latch.is_https) + self.assertEqual(latch.port, 443) + self.assertEqual(latch.host, "foo.bar.com") + + latch.reconfigure_mock.assert_called() + latch.reconfigure_mock.reset_mock() + + latch.host = "https://foo.bar.com" + latch.reconfigure_mock.assert_not_called() + + def test_set_host_url_https_port(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "http://init.bar.com" + + self.assertFalse(latch.is_https) + self.assertEqual(latch.port, 80) + self.assertEqual(latch.host, "init.bar.com") + + latch.host = "https://foo.bar.com:8443" + + self.assertTrue(latch.is_https) + self.assertEqual(latch.port, 8443) + self.assertEqual(latch.host, "foo.bar.com") + + latch.reconfigure_mock.assert_called() + latch.reconfigure_mock.reset_mock() + + latch.host = "https://foo.bar.com:8443" + latch.reconfigure_mock.assert_not_called() + + def test_set_host_fail(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + with self.assertRaises(ValueError): + latch.host = "magnet://init.bar.com" + + def test_set_proxy(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.proxy_host = "proxy.bar.com" + + self.assertIsNone(latch.proxy_port) + self.assertEqual(latch.proxy_host, "proxy.bar.com") + + latch.reconfigure_mock.assert_called() + latch.reconfigure_mock.reset_mock() + + latch.proxy_host = "proxy.bar.com" + latch.reconfigure_mock.assert_not_called() + + def test_set_proxy_port(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.proxy_host = "proxy.bar.com:8443" + + self.assertEqual(latch.proxy_port, 8443) + self.assertEqual(latch.proxy_host, "proxy.bar.com") + + latch.reconfigure_mock.assert_called() + latch.reconfigure_mock.reset_mock() + + latch.proxy_host = "proxy.bar.com:8443" + latch.reconfigure_mock.assert_not_called() + + def test_build_url(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "foo.bar.com" + + self.assertEqual( + latch.build_url("/test", "param1=1¶m2=2"), + "https://foo.bar.com/test?param1=1¶m2=2", + ) + + def test_build_url_port(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "foo.bar.com:8443" + + self.assertEqual( + latch.build_url("/test", "param1=1¶m2=2"), + "https://foo.bar.com:8443/test?param1=1¶m2=2", + ) + + def test_build_url_http(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "http://foo.bar.com" + + self.assertEqual( + latch.build_url("/test", "param1=1¶m2=2"), + "http://foo.bar.com/test?param1=1¶m2=2", + ) + + def test_build_url_http_port(self) -> None: + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=Mock()) + + latch.host = "http://foo.bar.com:8080" + + self.assertEqual( + latch.build_url("/test", "param1=1¶m2=2"), + "http://foo.bar.com:8080/test?param1=1¶m2=2", + ) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..daa4e3a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,42 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +import secrets +from unittest import TestCase + +from latch_sdk.utils import sign_data + + +class SignDataTestCase(TestCase): + def test_sign_data(self) -> None: + secret = b"ExZb4Vgnwtw0gWXgl22uO5AMWQW4JS3HNWa_NLmITbg" + data = ( + b"opimIQq-aan6NpC1fF0vqUtqa5sf2MGSYDsHboU-" + b"ONFcVQzVD9kLSS8gVizyli860BnfigrJVi7Uuf-hiTQZ28_6WOP_" + b"FkxlsnMrkuEAGRf0v5ghZgolfbBie6cVoTUilLZc0-WXcBqiwqd1" + b"UJbyV4mQ9RWbxzAT993MgBuo2MI" + ) + + self.assertEqual(sign_data(secret, data), b"0GbdoJVXwvDijaO26yKggJwZY8Q=") + + def test_sign_idem_potent(self) -> None: + secret = secrets.token_bytes(32) + data = secrets.token_bytes(128) + + self.assertEqual(sign_data(secret, data), sign_data(secret, data)) From d7ebc2fff363ff11f48c493a4a8e5e39a67e577e Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 25 Feb 2025 17:09:58 +0100 Subject: [PATCH 03/25] Remove src from .gitignore --- .gitignore | 1 - src/latch_sdk/__init__.py | 18 + src/latch_sdk/asyncio/__init__.py | 22 + src/latch_sdk/asyncio/aiohttp.py | 65 +++ src/latch_sdk/asyncio/base.py | 88 ++++ src/latch_sdk/asyncio/httpx.py | 71 +++ src/latch_sdk/cli/__init__.py | 55 +++ src/latch_sdk/cli/application.py | 196 ++++++++ src/latch_sdk/cli/renders.py | 138 ++++++ src/latch_sdk/cli/utils.py | 24 + src/latch_sdk/error.py | 52 ++ src/latch_sdk/exceptions.py | 123 +++++ src/latch_sdk/models.py | 269 +++++++++++ src/latch_sdk/py.typed | 0 src/latch_sdk/response.py | 89 ++++ src/latch_sdk/sansio.py | 766 ++++++++++++++++++++++++++++++ src/latch_sdk/syncio/__init__.py | 22 + src/latch_sdk/syncio/base.py | 88 ++++ src/latch_sdk/syncio/httpx.py | 71 +++ src/latch_sdk/syncio/pure.py | 82 ++++ src/latch_sdk/syncio/requests.py | 71 +++ src/latch_sdk/utils.py | 30 ++ 22 files changed, 2340 insertions(+), 1 deletion(-) create mode 100644 src/latch_sdk/__init__.py create mode 100644 src/latch_sdk/asyncio/__init__.py create mode 100644 src/latch_sdk/asyncio/aiohttp.py create mode 100644 src/latch_sdk/asyncio/base.py create mode 100644 src/latch_sdk/asyncio/httpx.py create mode 100644 src/latch_sdk/cli/__init__.py create mode 100644 src/latch_sdk/cli/application.py create mode 100644 src/latch_sdk/cli/renders.py create mode 100644 src/latch_sdk/cli/utils.py create mode 100644 src/latch_sdk/error.py create mode 100644 src/latch_sdk/exceptions.py create mode 100644 src/latch_sdk/models.py create mode 100644 src/latch_sdk/py.typed create mode 100644 src/latch_sdk/response.py create mode 100644 src/latch_sdk/sansio.py create mode 100644 src/latch_sdk/syncio/__init__.py create mode 100644 src/latch_sdk/syncio/base.py create mode 100644 src/latch_sdk/syncio/httpx.py create mode 100644 src/latch_sdk/syncio/pure.py create mode 100644 src/latch_sdk/syncio/requests.py create mode 100644 src/latch_sdk/utils.py diff --git a/.gitignore b/.gitignore index 09b893b..63544fd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ latch.pyc .env dist .python-version -src/ .DS_Store # Coverage diff --git a/src/latch_sdk/__init__.py b/src/latch_sdk/__init__.py new file mode 100644 index 0000000..76d921a --- /dev/null +++ b/src/latch_sdk/__init__.py @@ -0,0 +1,18 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" diff --git a/src/latch_sdk/asyncio/__init__.py b/src/latch_sdk/asyncio/__init__.py new file mode 100644 index 0000000..8b09dce --- /dev/null +++ b/src/latch_sdk/asyncio/__init__.py @@ -0,0 +1,22 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from .base import LatchSDK + +__all__ = ["LatchSDK"] diff --git a/src/latch_sdk/asyncio/aiohttp.py b/src/latch_sdk/asyncio/aiohttp.py new file mode 100644 index 0000000..60172e0 --- /dev/null +++ b/src/latch_sdk/asyncio/aiohttp.py @@ -0,0 +1,65 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from .base import BaseLatch +from ..response import LatchResponse + +try: + from aiohttp import ClientSession +except ImportError: # pragma: no cover + import warnings + + warnings.warn( + "Requests extra dependencies are not installed. Try to run: `pip install latch-sdk-telefonica[aiohttp]`" + ) + raise + +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + from typing import Mapping + + +class Latch(BaseLatch): + _session: ClientSession + + def _reconfigure_session(self) -> None: + proxy: str | None = None + if self.proxy_host: + proxy = f"http://{self.proxy_host}" + if self.proxy_port: + proxy = f"{proxy}:{self.proxy_port}" + + self._session = ClientSession(proxy=proxy) + + async def _http( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + """ + HTTP Request to the specified API endpoint + """ + async with self._session.request( + method, self.build_url(path), data=params, headers=headers + ) as response: + response.raise_for_status() + return LatchResponse(await response.json()) diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py new file mode 100644 index 0000000..9828c2e --- /dev/null +++ b/src/latch_sdk/asyncio/base.py @@ -0,0 +1,88 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from typing import Awaitable, Callable, Concatenate + +from ..response import LatchResponse +from ..sansio import ( + LatchSansIO, + LatchSDKSansIO, + P, + TFactory, + TLatch, + TReturnType, + response_add_instance, + response_history, + response_instance_list, + response_no_error, + response_operation, + response_operation_list, + response_pair, + response_status, +) + + +class BaseLatch(LatchSansIO[Awaitable[LatchResponse]]): + pass + + +def wrap_method( + factory: TFactory[TReturnType], + meth: Callable[Concatenate[TLatch, P], Awaitable[LatchResponse]], +) -> Callable[Concatenate["LatchSDK", P], Awaitable[TReturnType]]: + async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: + return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) + + wrapper.__doc__ = meth.__doc__ + wrapper.__name__ = meth.__name__ + + return wrapper + + +class LatchSDK(LatchSDKSansIO[Awaitable[LatchResponse]]): + + pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] + pair_with_id = wrap_method( + response_pair, + BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] + ) + + unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] + + status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] + + operation_status = wrap_method( + response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] + ) + + lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] + unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] + + create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] + update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] + delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] + get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] + + get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] + instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] + create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] + update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] + delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] + + history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] diff --git a/src/latch_sdk/asyncio/httpx.py b/src/latch_sdk/asyncio/httpx.py new file mode 100644 index 0000000..5372279 --- /dev/null +++ b/src/latch_sdk/asyncio/httpx.py @@ -0,0 +1,71 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +try: + from httpx import AsyncClient +except ImportError: # pragma: no cover + import warnings + + warnings.warn( + "Requests extra dependencies are not installed. Try to run: `pip install latch-sdk-telefonica[httx]`" + ) + raise + +from .base import BaseLatch +from ..response import LatchResponse + +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + from typing import Mapping + + +class Latch(BaseLatch): + _session: AsyncClient + + def _reconfigure_session(self) -> None: + proxy: str | None = None + if self.proxy_host: + proxy = f"http://{self.proxy_host}" + if self.proxy_port: + proxy = f"{proxy}:{self.proxy_port}" + + self._session = AsyncClient(proxy=proxy) + + async def _http( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + """ + HTTP Request to the specified API endpoint + """ + async with self._session as sess: + response = await sess.request( + method, + self.build_url(path), + data=params, + headers=headers, + ) + + response.raise_for_status() + + return LatchResponse(response.json()) diff --git a/src/latch_sdk/cli/__init__.py b/src/latch_sdk/cli/__init__.py new file mode 100644 index 0000000..65cbd0e --- /dev/null +++ b/src/latch_sdk/cli/__init__.py @@ -0,0 +1,55 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +import click + +from .application import application +from ..syncio import LatchSDK +from ..syncio.pure import Latch + + +@click.group(name="LatchSDK") +@click.option( + "--app-id", + "-a", + type=str, + required=True, + envvar="LATCH_APP_ID", + help="Application or user identifier", + show_envvar=True, +) +@click.option( + "--secret", + "-s", + type=str, + required=True, + envvar="LATCH_SECRET", + help="Secret or password", + show_envvar=True, +) +@click.pass_context +def latch_sdk(ctx: click.Context, app_id: str, secret: str): + ctx.obj = LatchSDK(Latch(app_id, secret)) + + +latch_sdk.add_command(application) + + +if __name__ == "__main__": + latch_sdk() diff --git a/src/latch_sdk/cli/application.py b/src/latch_sdk/cli/application.py new file mode 100644 index 0000000..b5488d0 --- /dev/null +++ b/src/latch_sdk/cli/application.py @@ -0,0 +1,196 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from datetime import datetime + +import click + +from .renders import ( + render_account, + render_history_response, + render_operations, + render_status, +) +from .utils import pass_latch_sdk +from ..syncio import LatchSDK + + +@click.group +def application(): + pass + + +@application.command() +@click.argument("TOKEN", type=str, required=True) +@click.option( + "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" +) +@click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") +@pass_latch_sdk +def pair( + latch: LatchSDK, + token: str, + web3_account: str | None = None, + web3_signature: str | None = None, +): + """ + Pair a new latch. + """ + result = latch.pair(token, web3_account=web3_account, web3_signature=web3_signature) + + render_account(result) + + +@application.command(name="pair-with-id") +@click.argument("USER_ID", type=str, required=True) +@click.option( + "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" +) +@click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") +@pass_latch_sdk +def pair_with_id( + latch: LatchSDK, + account_id: str, + web3_account: str | None = None, + web3_signature: str | None = None, +): + """ + Pair with user id a new latch. + """ + result = latch.pair_with_id( + account_id, web3_account=web3_account, web3_signature=web3_signature + ) + + render_account(result) + + +@application.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@pass_latch_sdk +def unpair( + latch: LatchSDK, + account_id: str, +): + """ + Unpair a latch + """ + latch.unpair(account_id) + + click.echo("Account unpaired") + + +@application.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option("--nootp", default=False, is_flag=True, required=False, help="Avoid OTP") +@click.option( + "--silent", + default=False, + is_flag=True, + required=False, + help="Do not push notification", +) +@pass_latch_sdk +def status(latch: LatchSDK, account_id: str, nootp: bool, silent: bool): + """ + Get latch status + """ + status = latch.status(account_id, nootp=nootp, silent=silent) + + render_status(status) + + +@application.command(name="operation-list") +@click.option( + "--operation-id", "-o", type=str, required=False, help="Operation identifier" +) +@pass_latch_sdk +def operation_list(latch: LatchSDK, operation_id: str | None): + operations = latch.get_operations(operation_id=operation_id) + + render_operations(operations) + + +@application.group(name="operation") +@click.option("--account-id", "-a", type=str, required=True, help="Account identifier") +@click.option( + "--operation-id", "-o", type=str, required=True, help="Operation identifier" +) +@click.pass_context +def operation(ctx: click.Context, account_id: str, operation_id: str): + ctx.obj = {"account_id": account_id, "operation_id": operation_id} + + +@operation.command(name="status") +@click.option("--nootp", default=False, is_flag=True, required=False, help="Avoid OTP") +@click.option( + "--silent", + default=False, + is_flag=True, + required=False, + help="Do not push notification", +) +@click.pass_context +@pass_latch_sdk +def operation_status(latch: LatchSDK, ctx: click.Context, nootp: bool, silent: bool): + status = latch.operation_status( + ctx.obj["account_id"], ctx.obj["operation_id"], nootp=nootp, silent=silent + ) + + render_status(status) + + +@application.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--operation-id", "-o", type=str, required=False, help="Operation identifier" +) +@pass_latch_sdk +def lock(latch: LatchSDK, account_id: str, operation_id: str | None): + latch.lock(account_id, operation_id=operation_id) + + click.secho("Latch locked", bg="red") + + +@application.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--operation-id", "-o", type=str, required=False, help="Operation identifier" +) +@pass_latch_sdk +def unlock(latch: LatchSDK, account_id: str, operation_id: str | None): + latch.unlock(account_id, operation_id=operation_id) + + click.secho("Latch unlocked", bg="green") + + +@application.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--from", "from_dt", type=click.DateTime(), required=False, help="Date time from" +) +@click.option( + "--to", "to_dt", type=click.DateTime(), required=False, help="Date time to" +) +@pass_latch_sdk +def history( + latch: LatchSDK, account_id: str, from_dt: datetime | None, to_dt: datetime | None +): + history = latch.history(account_id, from_dt=from_dt, to_dt=to_dt) + + render_history_response(history) diff --git a/src/latch_sdk/cli/renders.py b/src/latch_sdk/cli/renders.py new file mode 100644 index 0000000..28619f7 --- /dev/null +++ b/src/latch_sdk/cli/renders.py @@ -0,0 +1,138 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from typing import Iterable + +import click + +from ..models import ( + Application, + Client, + HistoryEntry, + HistoryResponse, + Operation, + Status, +) + + +def render_account(account: str): + click.echo(f"New account identifier: {account}") + + +def render_status(status: Status): + click.secho(f"Operation ID: {status.operation_id}", bg="blue") + if status.status: + click.secho("Latch is open: Operations are allowed", bg="green") + else: + click.secho("Latch is closed: Operations are not allowed", bg="red") + + if status.two_factor: + click.secho("Two factor", bg="cyan") + click.echo(f" Token: {status.two_factor.token}") + click.echo(f" Generated: {status.two_factor.generated}") + + if status.operations: + click.echo("Operations:\n") + for op in status.operations: + render_status(op) + click.echo("-" * 50) + + +def render_operations(operations: Iterable[Operation]): + click.echo("Operations:") + click.echo("-" * 50) + for op in operations: + render_operation(op) + click.echo("-" * 50) + + +def render_operation(operation: Operation, indent=""): + click.echo(f"{indent}Name: {operation.name}") + click.echo(f"{indent}Operation ID: {operation.operation_id}") + click.echo(f"{indent}Two factor: {operation.two_factor.value}") + click.echo(f"{indent}Lock on request: {operation.lock_on_request.value}") + + if operation.status is None: + return + + if operation.status: + click.secho(f"{indent}Latch is open: Operations are allowed", bg="green") + else: + click.secho(f"{indent}Latch is closed: Operations are not allowed", bg="red") + + +def render_history_response(history: HistoryResponse): + + render_application(history.application) + click.echo(f"Last seen: {history.last_seen}") + + render_clients(history.client_version) + + render_history(history.history, history.count) + + +def render_clients(clients: Iterable[Client], indent=""): + click.echo(f"{indent}Clients:") + click.echo(f"{indent} |" + "-" * 50) + for c in clients: + render_client(c, indent=indent + " |") + click.echo(f"{indent} |" + "-" * 50) + + +def render_client(client: Client, indent=""): + click.echo(f"{indent}Platform: {client.platform}") + click.echo(f"{indent}Version: {client.app}") + + +def render_application(app: Application, indent=""): + click.echo(f"{indent}{app.name}") + click.echo(f"{indent}{app.description}") + click.echo(f"{indent}Identifier: {app.application_id}") + click.echo(f"{indent}Image URL: {app.image_url}") + click.echo(f"{indent}Contact email: {app.contact_mail}") + click.echo(f"{indent}Contact phone: {app.contact_phone}") + click.echo(f"{indent}Two factor: {app.two_factor.value}") + click.echo(f"{indent}Lock on request: {app.lock_on_request.value}") + + click.echo(f"{indent}Paired on: {app.paired_on}") + click.echo(f"{indent}Last modified status: {app.status_last_modified}") + if app.operations: + click.echo(f"{indent}Operations:") + click.echo(indent + " |" + "-" * 50) + for op in app.operations: + render_operation(op, indent=indent + " |") + click.echo(indent + " |" + "-" * 50) + + +def render_history(history: Iterable[HistoryEntry], count: int, indent=""): + click.echo(f"{indent}History ({count}):") + click.echo("-" * 50) + for h in history: + render_history_entry(h, indent=indent + " |") + click.echo("-" * 50) + + +def render_history_entry(entry: HistoryEntry, indent=""): + click.echo(entry.name) + click.echo(f"Value: {entry.value}") + click.echo(f"Action: {entry.action}") + click.echo(f"Timestamp: {entry.t}") + click.echo(f"Was: {entry.was}") + click.echo(f"What: {entry.what}") + click.echo(f"From ip/user-agent: {entry.ip}") diff --git a/src/latch_sdk/cli/utils.py b/src/latch_sdk/cli/utils.py new file mode 100644 index 0000000..9d39fb0 --- /dev/null +++ b/src/latch_sdk/cli/utils.py @@ -0,0 +1,24 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +import click + +from ..syncio import LatchSDK + +pass_latch_sdk = click.make_pass_decorator(LatchSDK) diff --git a/src/latch_sdk/error.py b/src/latch_sdk/error.py new file mode 100644 index 0000000..991239b --- /dev/null +++ b/src/latch_sdk/error.py @@ -0,0 +1,52 @@ +""" + This library offers an API to use Latch in a python environment. + Copyright (C) 2013 Telefonica Digital España S.L. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +import json +from typing import TypedDict + + +class ErrorData(TypedDict): + code: int + message: str + + +class Error(object): + + def __init__(self, json_data: ErrorData): + """ + Constructor + """ + + self.code = json_data["code"] + self.message = json_data["message"] + + def get_code(self) -> int: + return self.code + + def get_message(self) -> str: + return self.message + + def to_json(self) -> ErrorData: + return {"code": self.code, "message": self.message} + + def __repr__(self) -> str: + return json.dumps(self.to_json()) + + def __str__(self) -> str: + return self.__repr__() diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py new file mode 100644 index 0000000..b94ae0f --- /dev/null +++ b/src/latch_sdk/exceptions.py @@ -0,0 +1,123 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +_errors: dict[int, type["BaseLatchException"]] = {} + + +class BaseLatchException(Exception): + + def __init_subclass__(cls) -> None: + if hasattr(cls, "CODE") and isinstance(cls.CODE, int): # type: ignore + _errors[cls.CODE] = cls # type: ignore + return super().__init_subclass__() + + def __init__(self, code: int, message: str, *args, **kwargs) -> None: + self.code = code + self.message = message + super().__init__(message, *args, **kwargs) + + def __new__(cls, code: int, message: str) -> "BaseLatchException": + if cls is BaseLatchException: + return cls.build(code, message) + return super().__new__(cls, code, message) + + @classmethod + def build(cls, code: int, message: str) -> "BaseLatchException": + try: + return _errors[code](code, message) + except KeyError: + return LatchError(code, message) + + +class LatchWarning(BaseLatchException): + pass + + +class LatchError(BaseLatchException): + pass + + +class InvalidCredentials(LatchError): + CODE = 105 + + +class UnauthorizedUser(LatchError): + CODE = 111 + + +class UnauthorizedScope(LatchError): + CODE = 113 + + +class AccountNotPaired(LatchError): + CODE = 201 + + +class InvalidAccountName(LatchError): + CODE = 202 + + +class PairingError(LatchError): + CODE = 203 + + +class UnpairingError(LatchError): + CODE = 204 + + +class ApplicationAlreadyPaired(LatchWarning): + CODE = 205 + + account_id: str | None = None + + +class TokenNotFound(LatchError): + CODE = 206 + + +class AccountDisabled(LatchError): + CODE = 207 + + +class ApplicationNotFound(LatchError): + CODE = 301 + + +class InstanceNotFound(LatchError): + CODE = 302 + + +class MaxActionsExceed(LatchError): + CODE = 303 + + +class NoTOTPServerConfigured(LatchError): + CODE = 304 + + +class TOTPNotFound(LatchError): + CODE = 305 + + +class InvalidTOTP(LatchError): + CODE = 306 + + +class TOPTGeneratingError(LatchError): + CODE = 307 diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py new file mode 100644 index 0000000..0a9ade3 --- /dev/null +++ b/src/latch_sdk/models.py @@ -0,0 +1,269 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Iterable, Self + + +@dataclass(frozen=True, kw_only=True, slots=True) +class TwoFactor: + token: str + generated: datetime + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + data.update(kwargs) + + return cls( + token=data["token"], + generated=datetime.fromtimestamp(data["generated"] / 1000, tz=timezone.utc), + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Status: + operation_id: str + + #: True means the latch is closed and any action must be blocked. + status: bool + two_factor: TwoFactor | None = None + operations: Iterable["Status"] | None = None + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + data.update(kwargs) + + return cls( + operation_id=data["operation_id"], + status=data.get("status", "off") == "on", + two_factor=( + TwoFactor.build_from_dict(data["two_factor"]) + if "two_factor" in data + else None + ), + operations=( + [ + cls.build_from_dict(d, operation_id=i) + for i, d in data["operations"].items() + ] + if "operations" in data + else None + ), + ) + + +class ExtraFeature(str, Enum): + MANDATORY = "MANDATORY" + OPT_IN = "OPT_IN" + DISABLED = "DISABLED" + + +class ExtraFeatureStatus(str, Enum): + MANDATORY = "MANDATORY" + OPT_IN = "OPT_IN" + DISABLED = "DISABLED" + + ON = "on" + OFF = "off" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Operation: + operation_id: str + + name: str + parent_id: str | None = None + + two_factor: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED + lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED + + #: True means the latch is closed and any action must be blocked. + status: bool | None = None + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + data.update(kwargs) + + return cls( + operation_id=data["operation_id"], + name=data["name"], + parent_id=data.get("parentId", None), + two_factor=( + ExtraFeatureStatus(data["two_factor"]) + if "two_factor" in data + else ExtraFeatureStatus.DISABLED + ), + lock_on_request=( + ExtraFeatureStatus(data["lock_on_request"]) + if "lock_on_request" in data + else ExtraFeatureStatus.DISABLED + ), + status=data["status"] == "on" if "status" in data else None, + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Instance: + instance_id: str + + name: str + + two_factor: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED + lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + data.update(kwargs) + + return cls( + instance_id=data["instance_id"], + name=data["name"], + two_factor=( + ExtraFeatureStatus(data["two_factor"]) + if "two_factor" in data + else ExtraFeatureStatus.DISABLED + ), + lock_on_request=( + ExtraFeatureStatus(data["lock_on_request"]) + if "lock_on_request" in data + else ExtraFeatureStatus.DISABLED + ), + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class HistoryEntry: + t: datetime + action: str + what: str + was: Any | None + value: Any | None + name: str + ip: str + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + data.update(kwargs) + + return cls( + t=datetime.fromtimestamp(data["t"] / 1000, timezone.utc), + action=data["action"], + what=data["what"], + was=data.get("was", None), + value=data.get("value", None), + name=data["name"], + ip=data["ip"], + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Application: + application_id: str + name: str + description: str + image_url: str + status: bool + paired_on: datetime + status_last_modified: datetime + autoclose: int + + contact_mail: str | None = None + contact_phone: str | None = None + + two_factor: ExtraFeature = ExtraFeature.DISABLED + lock_on_request: ExtraFeature = ExtraFeature.DISABLED + + operations: Iterable["Operation"] | None = None + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + data.update(kwargs) + + return cls( + name=data["name"], + description=data["description"], + image_url=data["imageURL"], + application_id=data["application_id"], + contact_mail=data.get("contactMail", None), + contact_phone=data.get("contactPhone", None), + two_factor=( + ExtraFeature(data["two_factor"]) + if "two_factor" in data + else ExtraFeature.DISABLED + ), + lock_on_request=( + ExtraFeature(data["lock_on_request"]) + if "lock_on_request" in data + else ExtraFeature.DISABLED + ), + status=data.get("status", "off") == "on", + autoclose=data.get("autoclose", 0), + paired_on=datetime.fromtimestamp(data["pairedOn"] / 1000, tz=timezone.utc), + status_last_modified=datetime.fromtimestamp( + data["statusLastModified"] / 1000, tz=timezone.utc + ), + operations=( + [ + Operation.build_from_dict(d, operation_id=i) + for i, d in data["operations"].items() + ] + if "operations" in data + else None + ), + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class Client: + platform: str + app: str + + +@dataclass(frozen=True, kw_only=True, slots=True) +class HistoryResponse: + application: Application + client_version: Iterable[Client] + count: int + history: Iterable[HistoryEntry] + + last_seen: datetime | None = None + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + data.update(kwargs) + + app_id = next( + iter(set(data.keys()) - {"count", "clientVersion", "lastSeen", "history"}) + ) + + return cls( + application=Application.build_from_dict( + data[app_id], application_id=app_id + ), + client_version=[Client(**d) for d in data["clientVersion"]], + last_seen=( + datetime.fromtimestamp(data["lastSeen"] / 1000, timezone.utc) + if "lastSeen" in data + else None + ), + count=data.get("count", 0), + history=[HistoryEntry.build_from_dict(h) for h in data["history"]], + ) diff --git a/src/latch_sdk/py.typed b/src/latch_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/latch_sdk/response.py b/src/latch_sdk/response.py new file mode 100644 index 0000000..84314f8 --- /dev/null +++ b/src/latch_sdk/response.py @@ -0,0 +1,89 @@ +""" + This library offers an API to use Latch in a python environment. + Copyright (C) 2013 Telefonica Digital España S.L. + + This library is free software you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +import json +from typing import Any + +from .error import Error, ErrorData + + +class LatchResponse(object): + """ + This class models a response from any of the endpoints in the Latch API. + It consists of a "data" and an "error" elements. Although normally only one of them will be + present, they are not mutually exclusive, since errors can be non fatal, and therefore a response + could have valid information in the data field and at the same time inform of an error. + """ + + def __init__(self, data: str | dict[str, Any]): + """ + @param $json a json string received from one of the methods of the Latch API + """ + json_object: dict[str, Any] + + if isinstance(data, str): + json_object = json.loads(data) + else: + json_object = data + + self.data: dict[str, Any] | None = None + if "data" in json_object: + self.data = json_object["data"] + + self.error: Error | None = None + if "error" in json_object: + self.error = Error(json_object["error"]) + + def get_data(self) -> dict[str, Any] | None: + """ + @return JsonObject the data part of the API response + """ + return self.data + + def set_data(self, data: str): + """ + @param $data the data to include in the API response + """ + self.data = json.loads(data) + + def get_error(self) -> Error | None: + """ + @return Error the error part of the API response, consisting of an error code and an error message + """ + return self.error + + def set_error(self, error: ErrorData): + """ + @param $error an error to include in the API response + """ + self.error = Error(error) + + def to_json(self): + """ + @return a Json object with the data and error parts set if they exist + """ + json_response = {} + + if self.data: + json_response["data"] = self.data + + if self.error: + json_response["error"] = self.error.to_json() + + return json_response diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py new file mode 100644 index 0000000..d92b396 --- /dev/null +++ b/src/latch_sdk/sansio.py @@ -0,0 +1,766 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from string import Template +from typing import ( + Any, + Awaitable, + Callable, + Generic, + Iterable, + Literal, + ParamSpec, + TypeAlias, + TypedDict, + TypeVar, + cast, +) +from urllib.parse import urlparse + +from .exceptions import ApplicationAlreadyPaired, BaseLatchException +from .models import ExtraFeature, HistoryResponse, Instance, Operation, Status +from .response import LatchResponse + +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + from typing import Mapping # type: ignore + + +TResponse = TypeVar("TResponse", Awaitable[LatchResponse], LatchResponse) +# TResponse: TypeAlias = Awaitable[LatchResponse] | LatchResponse +TReturnType = TypeVar("TReturnType") +P = ParamSpec("P") + + +class Paths(TypedDict): + check_status: str + pair: str + pair_with_id: str + unpair: str + lock: str + unlock: str + history: str + operation: str + subscription: str + application: str + instance: str + + +class LatchSansIO(ABC, Generic[TResponse]): + API_PATH_CHECK_STATUS_PATTERN = "/api/${version}/status" + API_PATH_PAIR_PATTERN = "/api/${version}/pair" + API_PATH_PAIR_WITH_ID_PATTERN = "/api/${version}/pairWithId" + API_PATH_UNPAIR_PATTERN = "/api/${version}/unpair" + API_PATH_LOCK_PATTERN = "/api/${version}/lock" + API_PATH_UNLOCK_PATTERN = "/api/${version}/unlock" + API_PATH_HISTORY_PATTERN = "/api/${version}/history" + API_PATH_OPERATION_PATTERN = "/api/${version}/operation" + API_PATH_SUBSCRIPTION_PATTERN = "/api/${version}/subscription" + API_PATH_APPLICATION_PATTERN = "/api/${version}/application" + API_PATH_INSTANCE_PATTERN = "/api/${version}/instance" + + AUTHORIZATION_HEADER_NAME = "Authorization" + DATE_HEADER_NAME = "X-11Paths-Date" + AUTHORIZATION_METHOD = "11PATHS" + AUTHORIZATION_HEADER_FIELD_SEPARATOR = " " + + UTC_STRING_FORMAT = "%Y-%m-%d %H:%M:%S" + + X_11PATHS_HEADER_PREFIX = "X-11paths-" + X_11PATHS_HEADER_SEPARATOR = ":" + + def __init__( + self, + app_id: str, + secret_key: str, + *, + api_version: str = "1.0", + host: str = "latch.telefonica.com", + port: int = 443, + is_https: bool = True, + proxy_host: str | None = None, + proxy_port: int | None = None, + ) -> None: + self.app_id = app_id + self.secret_key = secret_key + + self._api_version = api_version + self._host = host + self._port = port + self._is_https = is_https + self._proxy_host = proxy_host + self._proxy_port = proxy_port + + self._paths = self.build_paths(self._api_version) + + self._reconfigure_session() + + return super().__init__() + + @property + def host(self) -> str: + return self._host + + @host.setter + def host(self, value: str): + if not value.startswith("https://") and not value.startswith("http://"): + if ":" not in value: + host = value.strip() + else: + host, port = value.split(":", 1) + self.port = int(port.strip()) + host = host.strip() + + if self._host == host: + return + + self._host = host + self._reconfigure_session() + return + + parsed = urlparse(value, allow_fragments=False) + + self.is_https = parsed.scheme == "https" + self.port = parsed.port if parsed.port else 443 if self.is_https else 80 + + if self._host == parsed.hostname: + return + + assert parsed.hostname is not None, "No hostname" + + self._host = parsed.hostname + self._reconfigure_session() + + @property + def port(self) -> int: + return self._port + + @port.setter + def port(self, value: int): + if self._port == value: + return + + self._port = value + self._reconfigure_session() + + @property + def is_https(self) -> bool: + return self._is_https + + @is_https.setter + def is_https(self, value: bool): + if self._is_https == value: + return + + self._is_https = value + self._reconfigure_session() + + @property + def proxy_host(self) -> str | None: + return self._proxy_host + + @proxy_host.setter + def proxy_host(self, value: str | None): + if value and ":" in value: + value, port = value.split(":", 1) + self.proxy_port = int(port) + + if self._proxy_host == value: + return + + self._proxy_host = value + self._reconfigure_session() + + @property + def proxy_port(self) -> int | None: + return self._proxy_port + + @proxy_port.setter + def proxy_port(self, value: int | None): + if self._proxy_port == value: + return + + self._proxy_port = value + self._reconfigure_session() + + @classmethod + def build_paths(cls, api_version: str) -> Paths: + return { + "check_status": Template(cls.API_PATH_CHECK_STATUS_PATTERN).safe_substitute( + {"version": api_version} + ), + "pair": Template(cls.API_PATH_PAIR_PATTERN).safe_substitute( + {"version": api_version} + ), + "pair_with_id": Template(cls.API_PATH_PAIR_WITH_ID_PATTERN).safe_substitute( + {"version": api_version} + ), + "unpair": Template(cls.API_PATH_UNPAIR_PATTERN).safe_substitute( + {"version": api_version} + ), + "lock": Template(cls.API_PATH_LOCK_PATTERN).safe_substitute( + {"version": api_version} + ), + "unlock": Template(cls.API_PATH_UNLOCK_PATTERN).safe_substitute( + {"version": api_version} + ), + "history": Template(cls.API_PATH_HISTORY_PATTERN).safe_substitute( + {"version": api_version} + ), + "operation": Template(cls.API_PATH_OPERATION_PATTERN).safe_substitute( + {"version": api_version} + ), + "subscription": Template(cls.API_PATH_SUBSCRIPTION_PATTERN).safe_substitute( + {"version": api_version} + ), + "application": Template(cls.API_PATH_APPLICATION_PATTERN).safe_substitute( + {"version": api_version} + ), + "instance": Template(cls.API_PATH_INSTANCE_PATTERN).safe_substitute( + {"version": api_version} + ), + } + + @classmethod + def get_part_from_header(cls, part: int, header: str) -> str: + """ + The custom header consists of three parts, the method, the appId and the signature. + This method returns the specified part if it exists. + + :param part: The zero indexed part to be returned + :param header: The HTTP header value from which to extract the part + :return: The specified part from the header or an empty string if not existent + """ + header = header.strip(cls.AUTHORIZATION_HEADER_FIELD_SEPARATOR) + if not header: + raise IndexError(part) + + return header.split(cls.AUTHORIZATION_HEADER_FIELD_SEPARATOR)[part] + + @classmethod + def get_auth_method_from_header(cls, authorization_header: str) -> str: + """ + :param authorization_header: Authorization HTTP Header + :return: The Authorization method. Typical values are "Basic", "Digest" or "11PATHS" + """ + + return cls.get_part_from_header(0, authorization_header) + + @classmethod + def get_app_id_from_header(cls, authorization_header: str) -> str: + """ + :param authorization_header: Authorization HTTP Header + :return: The requesting application Id. Identifies the application using the API + """ + return cls.get_part_from_header(1, authorization_header) + + @classmethod + def get_signature_from_header(cls, authorization_header: str) -> str: + """ + :param authorization_header: Authorization HTTP Header + :return: The signature of the current request. Verifies the identity of the application using the API + """ + return cls.get_part_from_header(2, authorization_header) + + @classmethod + def get_current_UTC(cls) -> str: + """ + :return: a string representation of the current time in UTC to be used in a Date HTTP Header + """ + return datetime.now(tz=timezone.utc).strftime(cls.UTC_STRING_FORMAT) + + def sign_data(self, data: str) -> str: + """ + :param data: the string to sign + :return: base64 encoding of the HMAC-SHA1 hash of the data parameter using + {@code secretKey} as cipher key. + """ + from .utils import sign_data + + return sign_data(self.secret_key.encode(), data.encode()).decode() + + def authentication_headers( + self, + http_method: str, + query_string: str, + x_headers: Mapping[str, str] | None = None, + utc: str | None = None, + params: Mapping[str, str] | None = None, + ) -> dict[str, str]: + """ + Calculate the authentication headers to be sent with a request to the API + + :param http_method: the HTTP Method, currently only GET is supported + :param query_string: the urlencoded string including the path (from the first forward slash) and the parameters + :param x_headers: HTTP headers specific to the 11-paths API. null if not needed. + :param utc: the Universal Coordinated Time for the Date HTTP header + :return: A map with the Authorization and Date headers needed to sign a Latch API request + """ + utc = utc or self.get_current_UTC() + + authorization_header = self.AUTHORIZATION_HEADER_FIELD_SEPARATOR.join( + [ + self.AUTHORIZATION_METHOD, + self.app_id, + self.sign_data( + self.build_data_to_sign( + http_method, utc, query_string, x_headers, params + ) + ), + ] + ) + + return { + self.AUTHORIZATION_HEADER_NAME: authorization_header, + self.DATE_HEADER_NAME: utc, + } + + @classmethod + def build_data_to_sign( + cls, + method: str, + utc: str, + path_and_query: str, + headers: Mapping[str, str] | None = None, + params: Mapping[str, str] | None = None, + ) -> str: + parts: list[str] = [ + method.upper().strip(), + utc.strip(), + cls.get_serialized_headers(headers), + path_and_query.strip(), + ] + + if params is not None: + parts.append(cls.get_serialized_params(params)) + + return "\n".join(parts) + + @classmethod + def get_serialized_headers(cls, headers: Mapping[str, str] | None) -> str: + """ + Prepares and returns a string ready to be signed from the 11-paths specific HTTP headers received + :param x_headers: a non neccesarily ordered map (array without duplicates) of the HTTP headers to be ordered. + :return: string The serialized headers, an empty string if no headers are passed, or None if there's a problem + such as non 11paths specific headers + """ + if not headers: + return "" + + sorted_headers = sorted(list((k.lower(), v) for k, v in headers.items())) + + if any( + [ + not key.startswith(cls.X_11PATHS_HEADER_PREFIX.lower()) + for key, _ in sorted_headers + ] + ): + raise ValueError( + "Error serializing headers." + f" Only specific {cls.X_11PATHS_HEADER_PREFIX} headers need to be singed" + ) + + return " ".join( + [cls.X_11PATHS_HEADER_SEPARATOR.join(v) for v in sorted_headers] + ) + + @classmethod + def get_serialized_params(cls, params: Mapping[str, str]) -> str: + from urllib.parse import urlencode + + return urlencode(sorted(params.items()), doseq=True) + + def _reconfigure_session(self): # pragma: no cover + pass + + def build_url(self, path: str, query: str | None = None) -> str: + from urllib.parse import urlunparse + + netloc = self.host + if (self.is_https and self.port != 443) or ( + not self.is_https and self.port != 80 + ): + netloc = f"{netloc}:{self.port}" + + return urlunparse( + ("https" if self.is_https else "http", netloc, path, None, query, None) + ) + + def _prepare_http( + self, + method: str, + path: str, + x_headers: Mapping[str, str] | None = None, + params: Mapping[str, str] | None = None, + ) -> TResponse: + headers = self.authentication_headers(method, path, x_headers, None, params) + if method == "POST" or method == "PUT": + headers["Content-type"] = "application/x-www-form-urlencoded" + + return self._http(method, path, headers=headers, params=params) + + @abstractmethod + def _http( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> TResponse: # pragma: no cover + raise NotImplementedError("Client http must be implemented") + + def pair_with_id( + self, + account_id: str, + *, + web3_account: str | None = None, + web3_signature: str | None = None, + ) -> TResponse: + path = "/".join((self._paths["pair_with_id"], account_id)) + + if web3_account is None or web3_signature is None: + return self._prepare_http("GET", path) + + params = {"wallet": web3_account, "signature": web3_signature} + return self._prepare_http("POST", path, None, params) + + def pair( + self, + token: str, + *, + web3_account: str | None = None, + web3_signature: str | None = None, + ) -> TResponse: + path = "/".join((self._paths["pair"], token)) + + if web3_account is None or web3_signature is None: + return self._prepare_http("GET", path) + + params = {"wallet": web3_account, "signature": web3_signature} + return self._prepare_http("POST", path, None, params) + + def status( + self, account_id: str, *, silent: bool = False, nootp: bool = False + ) -> TResponse: + parts = [self._paths["check_status"], account_id] + + if nootp: + parts.append("nootp") + if silent: + parts.append("silent") + + return self._prepare_http("GET", "/".join(parts)) + + def operation_status( + self, account_id: str, operation_id: str, *, silent=False, nootp=False + ) -> TResponse: + parts = [self._paths["check_status"], account_id, "op", operation_id] + + if nootp: + parts.append("nootp") + if silent: + parts.append("silent") + + return self._prepare_http("GET", "/".join(parts)) + + def unpair(self, account_id: str) -> TResponse: + return self._prepare_http("GET", "/".join((self._paths["unpair"], account_id))) + + def lock(self, account_id: str, *, operation_id: str | None = None) -> TResponse: + parts = [self._paths["lock"], account_id] + + if operation_id is not None: + parts.extend(("op", operation_id)) + + return self._prepare_http("POST", "/".join(parts)) + + def unlock(self, account_id: str, *, operation_id: str | None = None): + parts = [self._paths["unlock"], account_id] + + if operation_id is not None: + parts.extend(("op", operation_id)) + + return self._prepare_http("POST", "/".join(parts)) + + def history( + self, + account_id: str, + *, + from_dt: datetime | None = None, + to_dt: datetime | None = None, + ): + from_dt = from_dt or datetime.fromtimestamp(0, tz=timezone.utc) + to_dt = to_dt or datetime.now(tz=timezone.utc) + + return self._prepare_http( + "GET", + "/".join( + ( + self._paths["history"], + account_id, + str(round(from_dt.timestamp() * 1000)), + str(round(to_dt.timestamp() * 1000)), + ) + ), + ) + + def create_operation( + self, + parent_id: str, + name: str, + *, + two_factor: ExtraFeature = ExtraFeature.DISABLED, + lock_on_request: ExtraFeature = ExtraFeature.DISABLED, + ): + params = { + "parentId": parent_id, + "name": name, + "two_factor": two_factor.value, + "lock_on_request": lock_on_request.value, + } + return self._prepare_http("PUT", self._paths["operation"], None, params) + + def update_operation( + self, + operation_id: str, + name: str, + *, + two_factor: ExtraFeature | None = None, + lock_on_request: ExtraFeature | None = None, + ): + params = { + "name": name, + } + if two_factor: + params["two_factor"] = two_factor.value + + if lock_on_request: + params["lock_on_request"] = lock_on_request.value + return self._prepare_http( + "POST", "/".join((self._paths["operation"], operation_id)), None, params + ) + + def delete_operation(self, operation_id: str): + return self._prepare_http( + "DELETE", "/".join((self._paths["operation"], operation_id)) + ) + + def get_operations(self, *, operation_id: str | None = None): + parts = [self._paths["operation"]] + + if operation_id is not None: + parts.append(operation_id) + + return self._prepare_http("GET", "/".join(parts)) + + def get_instances(self, account_id: str, *, operation_id: str | None = None): + parts = [self._paths["instance"], account_id] + + if operation_id is not None: + parts.extend(("op", operation_id)) + + return self._prepare_http("GET", "/".join(parts)) + + def instance_status( + self, + instance_id: str, + account_id: str, + operation_id: str | None = None, + silent: bool = False, + nootp: bool = False, + ): + parts = [self._paths["check_status"], account_id] + if operation_id is not None: + parts.extend(("op", operation_id)) + + parts.extend(("i", instance_id)) + + if nootp: + parts.append("nootp") + if silent: + parts.append("silent") + + return self._prepare_http("GET", "/".join(parts)) + + def create_instance( + self, name: str, account_id: str, *, operation_id: str | None = None + ): + # Only one at a time + params = {"instances": name} + + parts = [self._paths["instance"], account_id] + + if operation_id is not None: + parts.extend(("op", operation_id)) + + return self._prepare_http( + "PUT", + "/".join(parts), + None, + params, + ) + + def update_instance( + self, + instance_id: str, + account_id: str, + *, + operation_id: str | None = None, + name: str | None = None, + two_factor: ExtraFeature | None = None, + lock_on_request: ExtraFeature | None = None, + ): + params: dict[str, str] = {} + + if name: + params["name"] = name + + if two_factor: + params["two_factor"] = two_factor.value + + if lock_on_request: + params["lock_on_request"] = lock_on_request.value + + if len(params) == 0: + raise ValueError("No new data") + + parts = [self._paths["instance"], account_id] + + if operation_id is not None: + parts.extend(("op", operation_id)) + + parts.extend(("i", instance_id)) + + return self._prepare_http( + "POST", + "/".join(parts), + None, + params, + ) + + def delete_instance( + self, instance_id: str, account_id: str, operation_id: str | None = None + ): + parts = [self._paths["instance"], account_id] + + if operation_id is not None: + parts.extend(("op", operation_id)) + + parts.extend(("i", instance_id)) + + return self._prepare_http("DELETE", "/".join(parts)) + + +TLatch = TypeVar("TLatch", bound=LatchSansIO[LatchResponse]) +TFactory: TypeAlias = Callable[[LatchResponse], TReturnType] + + +class LatchSDKSansIO(Generic[TResponse]): + def __init__(self, core: LatchSansIO[TResponse]): + self._core: LatchSansIO[TResponse] = core + + +def check_error(resp: LatchResponse): + if not resp.error: + return + + raise BaseLatchException(resp.error.get_code(), resp.error.get_message()) + + +def response_pair(resp: LatchResponse) -> str: + try: + check_error(resp) + except ApplicationAlreadyPaired as ex: + ex.account_id = cast(str, resp.data["accountId"]) # type: ignore + + raise ex + + assert resp.data is not None, "No error or data" + + return cast(str, resp.data["accountId"]) + + +def response_no_error(resp: LatchResponse) -> Literal[True]: + check_error(resp) + + return True + + +def response_status(resp: LatchResponse) -> Status: + check_error(resp) + + assert resp.data is not None, "No error or data" + + op_id, status_data = cast(dict[str, dict], resp.data["operations"]).popitem() + + return Status.build_from_dict(status_data, operation_id=op_id) + + +def response_operation(resp: LatchResponse) -> Operation: + check_error(resp) + + assert resp.data is not None, "No error or data" + + data: dict[str, dict[str, Any]] + if "operations" in resp.data: + data = resp.data["operations"] + else: + data = resp.data + + op_id, operation_data = data.popitem() + + return Operation.build_from_dict(operation_data, operation_id=op_id) + + +def response_operation_list(resp: LatchResponse) -> Iterable[Operation]: + check_error(resp) + + assert resp.data is not None, "No error or data" + + return [ + Operation.build_from_dict(d, operation_id=i) + for i, d in resp.data.get("operations", {}).items() + ] + + +def response_instance_list(resp: LatchResponse) -> Iterable[Instance]: + check_error(resp) + + assert resp.data is not None, "No error or data" + + return [Instance.build_from_dict(d, instance_id=i) for i, d in resp.data.items()] + + +def response_add_instance(resp: LatchResponse) -> str: + check_error(resp) + + assert resp.data is not None, "No error or data" + + return next(iter(resp.data.keys())) + + +def response_history(resp: LatchResponse) -> HistoryResponse: + check_error(resp) + + assert resp.data is not None, "No error or data" + + return HistoryResponse.build_from_dict(resp.data) diff --git a/src/latch_sdk/syncio/__init__.py b/src/latch_sdk/syncio/__init__.py new file mode 100644 index 0000000..8b09dce --- /dev/null +++ b/src/latch_sdk/syncio/__init__.py @@ -0,0 +1,22 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from .base import LatchSDK + +__all__ = ["LatchSDK"] diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py new file mode 100644 index 0000000..2c50323 --- /dev/null +++ b/src/latch_sdk/syncio/base.py @@ -0,0 +1,88 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from typing import Callable, Concatenate + +from ..response import LatchResponse +from ..sansio import ( + LatchSansIO, + LatchSDKSansIO, + P, + TFactory, + TLatch, + TReturnType, + response_add_instance, + response_history, + response_instance_list, + response_no_error, + response_operation, + response_operation_list, + response_pair, + response_status, +) + + +class BaseLatch(LatchSansIO[LatchResponse]): + pass + + +def wrap_method( + factory: TFactory[TReturnType], + meth: Callable[Concatenate[TLatch, P], LatchResponse], +) -> Callable[Concatenate["LatchSDK", P], TReturnType]: + def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: + return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) + + wrapper.__doc__ = meth.__doc__ + wrapper.__name__ = meth.__name__ + + return wrapper + + +class LatchSDK(LatchSDKSansIO[LatchResponse]): + + pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] + pair_with_id = wrap_method( + response_pair, + BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] + ) + + unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] + + status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] + + operation_status = wrap_method( + response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] + ) + + lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] + unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] + + create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] + update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] + delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] + get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] + + get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] + instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] + create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] + update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] + delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] + + history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] diff --git a/src/latch_sdk/syncio/httpx.py b/src/latch_sdk/syncio/httpx.py new file mode 100644 index 0000000..f662276 --- /dev/null +++ b/src/latch_sdk/syncio/httpx.py @@ -0,0 +1,71 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +try: + from httpx import Client +except ImportError: # pragma: no cover + import warnings + + warnings.warn( + "Requests extra dependencies are not installed. Try to run: `pip install latch-sdk-telefonica[httpx]`" + ) + raise + +from .base import BaseLatch +from ..response import LatchResponse + +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + from typing import Mapping + + +class Latch(BaseLatch): + _session: Client + + def _reconfigure_session(self) -> None: + proxy: str | None = None + if self.proxy_host: + proxy = f"http://{self.proxy_host}" + if self.proxy_port: + proxy = f"{proxy}:{self.proxy_port}" + + self._session = Client(proxy=proxy) + + def _http( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + """ + HTTP Request to the specified API endpoint + """ + with self._session as sess: + response = sess.request( + method, + self.build_url(path), + data=params, + headers=headers, + ) + + response.raise_for_status() + + return LatchResponse(response.json()) diff --git a/src/latch_sdk/syncio/pure.py b/src/latch_sdk/syncio/pure.py new file mode 100644 index 0000000..ad2b28c --- /dev/null +++ b/src/latch_sdk/syncio/pure.py @@ -0,0 +1,82 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +from urllib.parse import urlunparse + +from .base import BaseLatch +from ..response import LatchResponse + +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + from typing import Mapping + + +class Latch(BaseLatch): + def _http( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + """ + HTTP Request to the specified API endpoint + """ + from http.client import HTTPConnection, HTTPSConnection + from urllib.parse import urlencode + + conn: HTTPSConnection | HTTPConnection + + url = path + + if self.proxy_host is not None: + conn = HTTPSConnection(self.proxy_host, self.proxy_port) + if self.is_https: + conn.set_tunnel(self.host, self.port) + else: + netloc = self.host + + if self.port != 80: + netloc = ":".join((netloc, str(self.port))) + + url = urlunparse(("http", netloc, path, None, None, None)) + else: + if self.is_https: + conn = HTTPSConnection(self.host, self.port) + else: + conn = HTTPConnection(self.host, self.port) + + try: + if params is not None: + parameters = urlencode(params) + + conn.request(method, url, parameters, headers=headers) + else: + conn.request(method, url, headers=headers) + + response = conn.getresponse() + + response_data = response.read().decode("utf8") + + print(response_data) + + return LatchResponse(response_data) + finally: + conn.close() diff --git a/src/latch_sdk/syncio/requests.py b/src/latch_sdk/syncio/requests.py new file mode 100644 index 0000000..fc64aa9 --- /dev/null +++ b/src/latch_sdk/syncio/requests.py @@ -0,0 +1,71 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + +try: + from requests import Session +except ImportError: # pragma: no cover + import warnings + + warnings.warn( + "Requests extra dependencies are not installed. Try to run: `pip install latch-sdk-telefonica[requests]`" + ) + raise + +from .base import BaseLatch +from ..response import LatchResponse + +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + from typing import Mapping + + +class Latch(BaseLatch): + _session: Session + + def _reconfigure_session(self): + self._session = Session() + + if self.proxy_host: + proxy = f"https://{self.proxy_host}" + if self.proxy_port: + proxy = f"{proxy}:{self.proxy_port}" + self._session.proxies.update({"http": proxy, "https": proxy}) + + def _http( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: Mapping[str, str] | None = None, + ) -> LatchResponse: + """ + HTTP Request to the specified API endpoint + """ + with self._session as sess: + response = sess.request( + method, + self.build_url(path), + data=params, + headers=headers, + ) + + response.raise_for_status() + + return LatchResponse(response.text) diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py new file mode 100644 index 0000000..e68925f --- /dev/null +++ b/src/latch_sdk/utils.py @@ -0,0 +1,30 @@ +""" +This library offers an API to use LatchAuth in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" + + +def sign_data( + secret: bytes, + data: bytes, +) -> bytes: + import hmac + from base64 import b64encode + from hashlib import sha1 + + sha1_hash = hmac.new(secret, data, sha1) + return b64encode(sha1_hash.digest()) From 912d96c2765f3e9d331479a2438de9aa67fccb72 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 25 Feb 2025 17:11:47 +0100 Subject: [PATCH 04/25] Install all extras --- Python.mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python.mk b/Python.mk index cf54a16..c3ba560 100644 --- a/Python.mk +++ b/Python.mk @@ -36,7 +36,7 @@ python-help: # Code recipes requirements: - ${POETRY_EXECUTABLE} install --no-interaction --no-ansi + ${POETRY_EXECUTABLE} install --no-interaction --no-ansi --all-extras black: ${POETRY_RUN} ruff format . From 91b304d3927af5a67b2c13ce437d98a31a2a4b1c Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 25 Feb 2025 17:18:57 +0100 Subject: [PATCH 05/25] Make compatible with old python versions --- src/latch_sdk/asyncio/base.py | 13 ++++++++----- src/latch_sdk/cli/renders.py | 1 - src/latch_sdk/error.py | 33 ++++++++++++++++----------------- src/latch_sdk/exceptions.py | 1 - src/latch_sdk/models.py | 19 +++++++++++-------- src/latch_sdk/response.py | 32 ++++++++++++++++---------------- src/latch_sdk/sansio.py | 1 - src/latch_sdk/syncio/base.py | 13 ++++++++----- 8 files changed, 59 insertions(+), 54 deletions(-) diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index 9828c2e..a1a45b6 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -17,7 +17,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ -from typing import Awaitable, Callable, Concatenate +from typing import TYPE_CHECKING, Awaitable, Callable from ..response import LatchResponse from ..sansio import ( @@ -37,6 +37,9 @@ response_status, ) +if TYPE_CHECKING: + from typing import Concatenate + class BaseLatch(LatchSansIO[Awaitable[LatchResponse]]): pass @@ -44,8 +47,8 @@ class BaseLatch(LatchSansIO[Awaitable[LatchResponse]]): def wrap_method( factory: TFactory[TReturnType], - meth: Callable[Concatenate[TLatch, P], Awaitable[LatchResponse]], -) -> Callable[Concatenate["LatchSDK", P], Awaitable[TReturnType]]: + meth: "Callable[Concatenate[TLatch, P], Awaitable[LatchResponse]]", +) -> "Callable[Concatenate[LatchSDK, P], Awaitable[TReturnType]]": async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) @@ -56,7 +59,6 @@ async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: class LatchSDK(LatchSDKSansIO[Awaitable[LatchResponse]]): - pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] pair_with_id = wrap_method( response_pair, @@ -68,7 +70,8 @@ class LatchSDK(LatchSDKSansIO[Awaitable[LatchResponse]]): status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] operation_status = wrap_method( - response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] + response_status, + BaseLatch.operation_status, # type: ignore[arg-type, type-var] ) lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] diff --git a/src/latch_sdk/cli/renders.py b/src/latch_sdk/cli/renders.py index 28619f7..d5b0a29 100644 --- a/src/latch_sdk/cli/renders.py +++ b/src/latch_sdk/cli/renders.py @@ -78,7 +78,6 @@ def render_operation(operation: Operation, indent=""): def render_history_response(history: HistoryResponse): - render_application(history.application) click.echo(f"Last seen: {history.last_seen}") diff --git a/src/latch_sdk/error.py b/src/latch_sdk/error.py index 991239b..756755d 100644 --- a/src/latch_sdk/error.py +++ b/src/latch_sdk/error.py @@ -1,20 +1,20 @@ """ - This library offers an API to use Latch in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +This library offers an API to use Latch in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ import json @@ -27,7 +27,6 @@ class ErrorData(TypedDict): class Error(object): - def __init__(self, json_data: ErrorData): """ Constructor diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py index b94ae0f..ef5e454 100644 --- a/src/latch_sdk/exceptions.py +++ b/src/latch_sdk/exceptions.py @@ -21,7 +21,6 @@ class BaseLatchException(Exception): - def __init_subclass__(cls) -> None: if hasattr(cls, "CODE") and isinstance(cls.CODE, int): # type: ignore _errors[cls.CODE] = cls # type: ignore diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index 0a9ade3..da5fd7e 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -20,7 +20,10 @@ from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import Any, Iterable, Self +from typing import TYPE_CHECKING, Any, Iterable + +if TYPE_CHECKING: + from typing import Self @dataclass(frozen=True, kw_only=True, slots=True) @@ -29,7 +32,7 @@ class TwoFactor: generated: datetime @classmethod - def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": data.update(kwargs) return cls( @@ -48,7 +51,7 @@ class Status: operations: Iterable["Status"] | None = None @classmethod - def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": data.update(kwargs) return cls( @@ -99,7 +102,7 @@ class Operation: status: bool | None = None @classmethod - def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": data.update(kwargs) return cls( @@ -130,7 +133,7 @@ class Instance: lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED @classmethod - def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": data.update(kwargs) return cls( @@ -160,7 +163,7 @@ class HistoryEntry: ip: str @classmethod - def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": data.update(kwargs) return cls( @@ -194,7 +197,7 @@ class Application: operations: Iterable["Operation"] | None = None @classmethod - def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": data.update(kwargs) return cls( @@ -247,7 +250,7 @@ class HistoryResponse: last_seen: datetime | None = None @classmethod - def build_from_dict(cls, data: dict[str, Any], **kwargs) -> Self: + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": data.update(kwargs) app_id = next( diff --git a/src/latch_sdk/response.py b/src/latch_sdk/response.py index 84314f8..78139ac 100644 --- a/src/latch_sdk/response.py +++ b/src/latch_sdk/response.py @@ -1,20 +1,20 @@ """ - This library offers an API to use Latch in a python environment. - Copyright (C) 2013 Telefonica Digital España S.L. - - This library is free software you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library if not, write to the Free Software - Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +This library offers an API to use Latch in a python environment. +Copyright (C) 2013 Telefonica Digital España S.L. + +This library is free software you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ import json diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index d92b396..79c5d24 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -46,7 +46,6 @@ TResponse = TypeVar("TResponse", Awaitable[LatchResponse], LatchResponse) -# TResponse: TypeAlias = Awaitable[LatchResponse] | LatchResponse TReturnType = TypeVar("TReturnType") P = ParamSpec("P") diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index 2c50323..dfa9b54 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -17,7 +17,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ -from typing import Callable, Concatenate +from typing import TYPE_CHECKING, Callable from ..response import LatchResponse from ..sansio import ( @@ -37,6 +37,9 @@ response_status, ) +if TYPE_CHECKING: + from typing import Concatenate + class BaseLatch(LatchSansIO[LatchResponse]): pass @@ -44,8 +47,8 @@ class BaseLatch(LatchSansIO[LatchResponse]): def wrap_method( factory: TFactory[TReturnType], - meth: Callable[Concatenate[TLatch, P], LatchResponse], -) -> Callable[Concatenate["LatchSDK", P], TReturnType]: + meth: "Callable[Concatenate[TLatch, P], LatchResponse]", +) -> "Callable[Concatenate[LatchSDK, P], TReturnType]": def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) @@ -56,7 +59,6 @@ def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: class LatchSDK(LatchSDKSansIO[LatchResponse]): - pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] pair_with_id = wrap_method( response_pair, @@ -68,7 +70,8 @@ class LatchSDK(LatchSDKSansIO[LatchResponse]): status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] operation_status = wrap_method( - response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] + response_status, + BaseLatch.operation_status, # type: ignore[arg-type, type-var] ) lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] From 6d787987182252ce4505a48ec28f1a8680f0be84 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 25 Feb 2025 17:22:20 +0100 Subject: [PATCH 06/25] Make compatible with old python versions --- src/latch_sdk/response.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/latch_sdk/response.py b/src/latch_sdk/response.py index 78139ac..3bf3ec0 100644 --- a/src/latch_sdk/response.py +++ b/src/latch_sdk/response.py @@ -23,7 +23,7 @@ from .error import Error, ErrorData -class LatchResponse(object): +class LatchResponse: """ This class models a response from any of the endpoints in the Latch API. It consists of a "data" and an "error" elements. Although normally only one of them will be @@ -31,7 +31,7 @@ class LatchResponse(object): could have valid information in the data field and at the same time inform of an error. """ - def __init__(self, data: str | dict[str, Any]): + def __init__(self, data: "str | dict[str, Any]"): """ @param $json a json string received from one of the methods of the Latch API """ @@ -50,7 +50,7 @@ def __init__(self, data: str | dict[str, Any]): if "error" in json_object: self.error = Error(json_object["error"]) - def get_data(self) -> dict[str, Any] | None: + def get_data(self) -> "dict[str, Any] | None": """ @return JsonObject the data part of the API response """ From d0157efd4559e8f4a1175779d408b89ba306c1d0 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 26 Feb 2025 08:56:58 +0100 Subject: [PATCH 07/25] Fix test datetimes and other fixes --- src/latch_sdk/asyncio/base.py | 2 +- src/latch_sdk/models.py | 2 +- src/latch_sdk/response.py | 2 +- src/latch_sdk/syncio/base.py | 2 +- tests/test_sansio.py | 26 +++++++++++++++++++++----- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index a1a45b6..9361519 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -37,7 +37,7 @@ response_status, ) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from typing import Concatenate diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index da5fd7e..17fed27 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -22,7 +22,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, Iterable -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from typing import Self diff --git a/src/latch_sdk/response.py b/src/latch_sdk/response.py index 3bf3ec0..be74305 100644 --- a/src/latch_sdk/response.py +++ b/src/latch_sdk/response.py @@ -62,7 +62,7 @@ def set_data(self, data: str): """ self.data = json.loads(data) - def get_error(self) -> Error | None: + def get_error(self) -> "Error | None": """ @return Error the error part of the API response, consisting of an error code and an error message """ diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index dfa9b54..316c194 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -37,7 +37,7 @@ response_status, ) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from typing import Concatenate diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 52437d9..9444e31 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -22,7 +22,7 @@ from textwrap import dedent from typing import Callable, Mapping from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch from latch_sdk.exceptions import ( ApplicationAlreadyPaired, @@ -1258,7 +1258,15 @@ def _http_cb( http_cb.assert_called_once() - def test_history_no_to(self) -> None: + @patch("latch_sdk.sansio.datetime") + def test_history_no_to(self, datetime_cls_mock: Mock) -> None: + current_dt: datetime = datetime.now(tz=timezone.utc) + + datetime_cls_mock.now.return_value = current_dt + datetime_cls_mock.fromtimestamp.side_effect = ( + lambda *args, **kwargs: datetime.fromtimestamp(*args, **kwargs) + ) + account_id = "eregerdscvrtrd" from_dt = datetime.now(tz=timezone.utc) - timedelta(days=2) @@ -1273,7 +1281,7 @@ def _http_cb( self.assertEqual( path, f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}" - f"/{round(round(datetime.now(tz=timezone.utc).timestamp() * 1000))}", + f"/{round(round(current_dt.timestamp() * 1000))}", ) self.assert_request(method, path, headers, params) @@ -1290,7 +1298,15 @@ def _http_cb( http_cb.assert_called_once() - def test_history_no_from_no_to(self) -> None: + @patch("latch_sdk.sansio.datetime") + def test_history_no_from_no_to(self, datetime_cls_mock: Mock) -> None: + current_dt: datetime = datetime.now(tz=timezone.utc) + + datetime_cls_mock.now.return_value = current_dt + datetime_cls_mock.fromtimestamp.side_effect = ( + lambda *args, **kwargs: datetime.fromtimestamp(*args, **kwargs) + ) + account_id = "eregerdscvrtrd" def _http_cb( @@ -1303,7 +1319,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/history/{account_id}/0/{round(datetime.now(tz=timezone.utc).timestamp() * 1000)}", + f"/api/1.0/history/{account_id}/0/{round(current_dt.timestamp() * 1000)}", ) self.assert_request(method, path, headers, params) From 1ba8d5a2688106cedf7fe5574e66005d2ec5f62b Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 26 Feb 2025 09:21:41 +0100 Subject: [PATCH 08/25] Fix support for python 3.9 --- .../latch_sdk_python_pull_request.yaml | 2 +- poetry.lock | 3 +- pyproject.toml | 1 + src/latch_sdk/asyncio/aiohttp.py | 4 +- src/latch_sdk/asyncio/base.py | 2 +- src/latch_sdk/asyncio/httpx.py | 4 +- src/latch_sdk/exceptions.py | 2 +- src/latch_sdk/models.py | 36 +++--- src/latch_sdk/response.py | 4 +- src/latch_sdk/sansio.py | 88 ++++++++------- src/latch_sdk/syncio/base.py | 2 +- src/latch_sdk/syncio/httpx.py | 4 +- src/latch_sdk/syncio/pure.py | 4 +- src/latch_sdk/syncio/requests.py | 2 +- tests/asyncio/test_base.py | 33 ------ tests/factory.py | 2 +- tests/syncio/test_base.py | 33 ------ tests/test_sansio.py | 106 +++++++++--------- 18 files changed, 138 insertions(+), 194 deletions(-) diff --git a/.github/workflows/latch_sdk_python_pull_request.yaml b/.github/workflows/latch_sdk_python_pull_request.yaml index 6be56bc..4331c1d 100644 --- a/.github/workflows/latch_sdk_python_pull_request.yaml +++ b/.github/workflows/latch_sdk_python_pull_request.yaml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] fail-fast: true steps: diff --git a/poetry.lock b/poetry.lock index 56eb378..43dd771 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1560,7 +1560,6 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {main = "extra == \"aiohttp\" and python_version < \"3.11\" or extra == \"httpx\" and python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1688,4 +1687,4 @@ requests = ["requests"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "83337f01779a01c8838b1af189507ad7b1bb24765e926d20ae6cfc8c207c350d" +content-hash = "7452b19f16538d028d56e04101246c3c10d5e4b6a135818292e560bcaa46f020" diff --git a/pyproject.toml b/pyproject.toml index 8684567..23258b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", ] +dependencies = ["typing-extensions (>=4.12.2,<5.0.0)"] [project.urls] "Homepage" = "https://github.com/Telefonica/latch-sdk-python" diff --git a/src/latch_sdk/asyncio/aiohttp.py b/src/latch_sdk/asyncio/aiohttp.py index 60172e0..9257a85 100644 --- a/src/latch_sdk/asyncio/aiohttp.py +++ b/src/latch_sdk/asyncio/aiohttp.py @@ -40,7 +40,7 @@ class Latch(BaseLatch): _session: ClientSession def _reconfigure_session(self) -> None: - proxy: str | None = None + proxy: "str | None" = None if self.proxy_host: proxy = f"http://{self.proxy_host}" if self.proxy_port: @@ -53,7 +53,7 @@ async def _http( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: """ HTTP Request to the specified API endpoint diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index 9361519..5e5721d 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -46,7 +46,7 @@ class BaseLatch(LatchSansIO[Awaitable[LatchResponse]]): def wrap_method( - factory: TFactory[TReturnType], + factory: "TFactory[TReturnType]", meth: "Callable[Concatenate[TLatch, P], Awaitable[LatchResponse]]", ) -> "Callable[Concatenate[LatchSDK, P], Awaitable[TReturnType]]": async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: diff --git a/src/latch_sdk/asyncio/httpx.py b/src/latch_sdk/asyncio/httpx.py index 5372279..6819c32 100644 --- a/src/latch_sdk/asyncio/httpx.py +++ b/src/latch_sdk/asyncio/httpx.py @@ -40,7 +40,7 @@ class Latch(BaseLatch): _session: AsyncClient def _reconfigure_session(self) -> None: - proxy: str | None = None + proxy: "str | None" = None if self.proxy_host: proxy = f"http://{self.proxy_host}" if self.proxy_port: @@ -53,7 +53,7 @@ async def _http( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: """ HTTP Request to the specified API endpoint diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py index ef5e454..3f798e5 100644 --- a/src/latch_sdk/exceptions.py +++ b/src/latch_sdk/exceptions.py @@ -83,7 +83,7 @@ class UnpairingError(LatchError): class ApplicationAlreadyPaired(LatchWarning): CODE = 205 - account_id: str | None = None + account_id: "str | None" = None class TokenNotFound(LatchError): diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index 17fed27..c2d68b9 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -26,7 +26,7 @@ from typing import Self -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class TwoFactor: token: str generated: datetime @@ -41,14 +41,14 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class Status: operation_id: str #: True means the latch is closed and any action must be blocked. status: bool - two_factor: TwoFactor | None = None - operations: Iterable["Status"] | None = None + two_factor: "TwoFactor | None" = None + operations: "Iterable[Status] | None" = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @@ -88,18 +88,18 @@ class ExtraFeatureStatus(str, Enum): OFF = "off" -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class Operation: operation_id: str name: str - parent_id: str | None = None + parent_id: "str | None" = None two_factor: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED #: True means the latch is closed and any action must be blocked. - status: bool | None = None + status: "bool | None" = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @@ -123,7 +123,7 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class Instance: instance_id: str @@ -152,13 +152,13 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class HistoryEntry: t: datetime action: str what: str - was: Any | None - value: Any | None + was: "Any | None" + value: "Any | None" name: str ip: str @@ -177,7 +177,7 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class Application: application_id: str name: str @@ -188,13 +188,13 @@ class Application: status_last_modified: datetime autoclose: int - contact_mail: str | None = None - contact_phone: str | None = None + contact_mail: "str | None" = None + contact_phone: "str | None" = None two_factor: ExtraFeature = ExtraFeature.DISABLED lock_on_request: ExtraFeature = ExtraFeature.DISABLED - operations: Iterable["Operation"] | None = None + operations: "Iterable[Operation] | None" = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @@ -234,20 +234,20 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class Client: platform: str app: str -@dataclass(frozen=True, kw_only=True, slots=True) +@dataclass(frozen=True) class HistoryResponse: application: Application client_version: Iterable[Client] count: int history: Iterable[HistoryEntry] - last_seen: datetime | None = None + last_seen: "datetime | None" = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": diff --git a/src/latch_sdk/response.py b/src/latch_sdk/response.py index be74305..505d43a 100644 --- a/src/latch_sdk/response.py +++ b/src/latch_sdk/response.py @@ -42,11 +42,11 @@ def __init__(self, data: "str | dict[str, Any]"): else: json_object = data - self.data: dict[str, Any] | None = None + self.data: "dict[str, Any] | None" = None if "data" in json_object: self.data = json_object["data"] - self.error: Error | None = None + self.error: "Error | None" = None if "error" in json_object: self.error = Error(json_object["error"]) diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index 79c5d24..d18ec20 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -21,14 +21,13 @@ from datetime import datetime, timezone from string import Template from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, Generic, Iterable, Literal, - ParamSpec, - TypeAlias, TypedDict, TypeVar, cast, @@ -44,6 +43,17 @@ except ImportError: # pragma: no cover from typing import Mapping # type: ignore +try: + from typing import ParamSpec +except ImportError: # pragma: no cover + from typing_extensions import ParamSpec + + +if TYPE_CHECKING: # pragma: no cover + from typing import ( + TypeAlias, + ) + TResponse = TypeVar("TResponse", Awaitable[LatchResponse], LatchResponse) TReturnType = TypeVar("TReturnType") @@ -96,8 +106,8 @@ def __init__( host: str = "latch.telefonica.com", port: int = 443, is_https: bool = True, - proxy_host: str | None = None, - proxy_port: int | None = None, + proxy_host: "str | None" = None, + proxy_port: "int | None" = None, ) -> None: self.app_id = app_id self.secret_key = secret_key @@ -174,11 +184,11 @@ def is_https(self, value: bool): self._reconfigure_session() @property - def proxy_host(self) -> str | None: + def proxy_host(self) -> "str | None": return self._proxy_host @proxy_host.setter - def proxy_host(self, value: str | None): + def proxy_host(self, value: "str | None"): if value and ":" in value: value, port = value.split(":", 1) self.proxy_port = int(port) @@ -190,11 +200,11 @@ def proxy_host(self, value: str | None): self._reconfigure_session() @property - def proxy_port(self) -> int | None: + def proxy_port(self) -> "int | None": return self._proxy_port @proxy_port.setter - def proxy_port(self, value: int | None): + def proxy_port(self, value: "int | None"): if self._proxy_port == value: return @@ -301,9 +311,9 @@ def authentication_headers( self, http_method: str, query_string: str, - x_headers: Mapping[str, str] | None = None, - utc: str | None = None, - params: Mapping[str, str] | None = None, + x_headers: "Mapping[str, str] | None" = None, + utc: "str | None" = None, + params: "Mapping[str, str] | None" = None, ) -> dict[str, str]: """ Calculate the authentication headers to be sent with a request to the API @@ -339,8 +349,8 @@ def build_data_to_sign( method: str, utc: str, path_and_query: str, - headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, + headers: "Mapping[str, str] | None" = None, + params: "Mapping[str, str] | None" = None, ) -> str: parts: list[str] = [ method.upper().strip(), @@ -355,7 +365,7 @@ def build_data_to_sign( return "\n".join(parts) @classmethod - def get_serialized_headers(cls, headers: Mapping[str, str] | None) -> str: + def get_serialized_headers(cls, headers: "Mapping[str, str] | None") -> str: """ Prepares and returns a string ready to be signed from the 11-paths specific HTTP headers received :param x_headers: a non neccesarily ordered map (array without duplicates) of the HTTP headers to be ordered. @@ -391,7 +401,7 @@ def get_serialized_params(cls, params: Mapping[str, str]) -> str: def _reconfigure_session(self): # pragma: no cover pass - def build_url(self, path: str, query: str | None = None) -> str: + def build_url(self, path: str, query: "str | None" = None) -> str: from urllib.parse import urlunparse netloc = self.host @@ -408,8 +418,8 @@ def _prepare_http( self, method: str, path: str, - x_headers: Mapping[str, str] | None = None, - params: Mapping[str, str] | None = None, + x_headers: "Mapping[str, str] | None" = None, + params: "Mapping[str, str] | None" = None, ) -> TResponse: headers = self.authentication_headers(method, path, x_headers, None, params) if method == "POST" or method == "PUT": @@ -422,8 +432,8 @@ def _http( self, method: str, path: str, - headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + headers: "Mapping[str, str]", + params: "Mapping[str, str] | None" = None, ) -> TResponse: # pragma: no cover raise NotImplementedError("Client http must be implemented") @@ -431,8 +441,8 @@ def pair_with_id( self, account_id: str, *, - web3_account: str | None = None, - web3_signature: str | None = None, + web3_account: "str | None" = None, + web3_signature: "str | None" = None, ) -> TResponse: path = "/".join((self._paths["pair_with_id"], account_id)) @@ -446,8 +456,8 @@ def pair( self, token: str, *, - web3_account: str | None = None, - web3_signature: str | None = None, + web3_account: "str | None" = None, + web3_signature: "str | None" = None, ) -> TResponse: path = "/".join((self._paths["pair"], token)) @@ -484,7 +494,7 @@ def operation_status( def unpair(self, account_id: str) -> TResponse: return self._prepare_http("GET", "/".join((self._paths["unpair"], account_id))) - def lock(self, account_id: str, *, operation_id: str | None = None) -> TResponse: + def lock(self, account_id: str, *, operation_id: "str | None" = None) -> TResponse: parts = [self._paths["lock"], account_id] if operation_id is not None: @@ -492,7 +502,7 @@ def lock(self, account_id: str, *, operation_id: str | None = None) -> TResponse return self._prepare_http("POST", "/".join(parts)) - def unlock(self, account_id: str, *, operation_id: str | None = None): + def unlock(self, account_id: str, *, operation_id: "str | None" = None): parts = [self._paths["unlock"], account_id] if operation_id is not None: @@ -504,8 +514,8 @@ def history( self, account_id: str, *, - from_dt: datetime | None = None, - to_dt: datetime | None = None, + from_dt: "datetime | None" = None, + to_dt: "datetime | None" = None, ): from_dt = from_dt or datetime.fromtimestamp(0, tz=timezone.utc) to_dt = to_dt or datetime.now(tz=timezone.utc) @@ -543,8 +553,8 @@ def update_operation( operation_id: str, name: str, *, - two_factor: ExtraFeature | None = None, - lock_on_request: ExtraFeature | None = None, + two_factor: "ExtraFeature | None" = None, + lock_on_request: "ExtraFeature | None" = None, ): params = { "name": name, @@ -563,7 +573,7 @@ def delete_operation(self, operation_id: str): "DELETE", "/".join((self._paths["operation"], operation_id)) ) - def get_operations(self, *, operation_id: str | None = None): + def get_operations(self, *, operation_id: "str | None" = None): parts = [self._paths["operation"]] if operation_id is not None: @@ -571,7 +581,7 @@ def get_operations(self, *, operation_id: str | None = None): return self._prepare_http("GET", "/".join(parts)) - def get_instances(self, account_id: str, *, operation_id: str | None = None): + def get_instances(self, account_id: str, *, operation_id: "str | None" = None): parts = [self._paths["instance"], account_id] if operation_id is not None: @@ -583,7 +593,7 @@ def instance_status( self, instance_id: str, account_id: str, - operation_id: str | None = None, + operation_id: "str | None" = None, silent: bool = False, nootp: bool = False, ): @@ -601,7 +611,7 @@ def instance_status( return self._prepare_http("GET", "/".join(parts)) def create_instance( - self, name: str, account_id: str, *, operation_id: str | None = None + self, name: str, account_id: str, *, operation_id: "str | None" = None ): # Only one at a time params = {"instances": name} @@ -623,10 +633,10 @@ def update_instance( instance_id: str, account_id: str, *, - operation_id: str | None = None, - name: str | None = None, - two_factor: ExtraFeature | None = None, - lock_on_request: ExtraFeature | None = None, + operation_id: "str | None" = None, + name: "str | None" = None, + two_factor: "ExtraFeature | None" = None, + lock_on_request: "ExtraFeature | None" = None, ): params: dict[str, str] = {} @@ -657,7 +667,7 @@ def update_instance( ) def delete_instance( - self, instance_id: str, account_id: str, operation_id: str | None = None + self, instance_id: str, account_id: str, operation_id: "str | None" = None ): parts = [self._paths["instance"], account_id] @@ -670,7 +680,7 @@ def delete_instance( TLatch = TypeVar("TLatch", bound=LatchSansIO[LatchResponse]) -TFactory: TypeAlias = Callable[[LatchResponse], TReturnType] +TFactory: "TypeAlias" = Callable[[LatchResponse], "TReturnType"] class LatchSDKSansIO(Generic[TResponse]): diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index 316c194..3005fcb 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -46,7 +46,7 @@ class BaseLatch(LatchSansIO[LatchResponse]): def wrap_method( - factory: TFactory[TReturnType], + factory: "TFactory[TReturnType]", meth: "Callable[Concatenate[TLatch, P], LatchResponse]", ) -> "Callable[Concatenate[LatchSDK, P], TReturnType]": def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: diff --git a/src/latch_sdk/syncio/httpx.py b/src/latch_sdk/syncio/httpx.py index f662276..8ec9f27 100644 --- a/src/latch_sdk/syncio/httpx.py +++ b/src/latch_sdk/syncio/httpx.py @@ -40,7 +40,7 @@ class Latch(BaseLatch): _session: Client def _reconfigure_session(self) -> None: - proxy: str | None = None + proxy: "str | None" = None if self.proxy_host: proxy = f"http://{self.proxy_host}" if self.proxy_port: @@ -53,7 +53,7 @@ def _http( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: """ HTTP Request to the specified API endpoint diff --git a/src/latch_sdk/syncio/pure.py b/src/latch_sdk/syncio/pure.py index ad2b28c..2fca375 100644 --- a/src/latch_sdk/syncio/pure.py +++ b/src/latch_sdk/syncio/pure.py @@ -34,7 +34,7 @@ def _http( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: """ HTTP Request to the specified API endpoint @@ -42,7 +42,7 @@ def _http( from http.client import HTTPConnection, HTTPSConnection from urllib.parse import urlencode - conn: HTTPSConnection | HTTPConnection + conn: "HTTPSConnection | HTTPConnection" url = path diff --git a/src/latch_sdk/syncio/requests.py b/src/latch_sdk/syncio/requests.py index fc64aa9..afddb1f 100644 --- a/src/latch_sdk/syncio/requests.py +++ b/src/latch_sdk/syncio/requests.py @@ -53,7 +53,7 @@ def _http( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: """ HTTP Request to the specified API endpoint diff --git a/tests/asyncio/test_base.py b/tests/asyncio/test_base.py index 7adfa11..d50bd85 100644 --- a/tests/asyncio/test_base.py +++ b/tests/asyncio/test_base.py @@ -60,36 +60,3 @@ async def test_pair_error_205_already_paired(self): await self.latch_sdk.pair("terwrw") self.assertEqual(ex.exception.account_id, "test_account") # type: ignore - - """ - pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] - pair_with_id = wrap_method( - response_pair, - BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] - ) - - unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] - - status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] - - operation_status = wrap_method( - response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] - ) - - lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] - unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] - - create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] - update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] - delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] - get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] - - get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] - instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] - create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] - update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] - delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] - - history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] - - """ diff --git a/tests/factory.py b/tests/factory.py index 557991a..ec37fc6 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -51,7 +51,7 @@ def pair_error_206_token_expired(cls, message: str = "Token not found or expired @classmethod def status( cls, - status: Literal["on"] | Literal["off"], + status: 'Literal["on"] | Literal["off"]', operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", ): return {"data": {"operations": {operation_id: {"status": status}}}} diff --git a/tests/syncio/test_base.py b/tests/syncio/test_base.py index db48b47..c390436 100644 --- a/tests/syncio/test_base.py +++ b/tests/syncio/test_base.py @@ -60,36 +60,3 @@ def test_pair_error_205_already_paired(self): self.latch_sdk.pair("terwrw") self.assertEqual(ex.exception.account_id, "test_account") # type: ignore - - """ - pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] - pair_with_id = wrap_method( - response_pair, - BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] - ) - - unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] - - status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] - - operation_status = wrap_method( - response_status, BaseLatch.operation_status # type: ignore[arg-type, type-var] - ) - - lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] - unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] - - create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] - update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] - delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] - get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] - - get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] - instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] - create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] - update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] - delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] - - history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] - - """ diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 9444e31..ae13385 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -553,7 +553,7 @@ def __init__( str, str, Mapping[str, str], - Mapping[str, str] | None, + "Mapping[str, str] | None", ], LatchResponse, ], @@ -570,7 +570,7 @@ def _http( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: return self._http_cb(method, path, headers, params) @@ -588,7 +588,7 @@ def assert_request( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ): self.assertIn(LatchTesting.DATE_HEADER_NAME, headers) self.assertIn(LatchTesting.AUTHORIZATION_HEADER_NAME, headers) @@ -624,7 +624,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -656,7 +656,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -695,7 +695,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -725,7 +725,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -762,7 +762,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -799,7 +799,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -831,7 +831,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -863,7 +863,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -895,7 +895,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -928,7 +928,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -961,7 +961,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -996,7 +996,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1031,7 +1031,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1065,7 +1065,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1092,7 +1092,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1120,7 +1120,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1147,7 +1147,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1175,7 +1175,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1204,7 +1204,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1235,7 +1235,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1274,7 +1274,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1313,7 +1313,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1343,7 +1343,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "PUT") @@ -1381,7 +1381,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1416,7 +1416,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1451,7 +1451,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1486,7 +1486,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -1528,7 +1528,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "DELETE") @@ -1560,7 +1560,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1592,7 +1592,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1626,7 +1626,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1659,7 +1659,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1695,7 +1695,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "PUT") @@ -1729,7 +1729,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "PUT") @@ -1763,7 +1763,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1796,7 +1796,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1831,7 +1831,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1866,7 +1866,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1902,7 +1902,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1938,7 +1938,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -1977,7 +1977,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -2016,7 +2016,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "GET") @@ -2054,7 +2054,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -2090,7 +2090,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -2126,7 +2126,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -2162,7 +2162,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -2207,7 +2207,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "POST") @@ -2245,7 +2245,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ): pass @@ -2266,7 +2266,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "DELETE") @@ -2300,7 +2300,7 @@ def _http_cb( method: str, path: str, headers: Mapping[str, str], - params: Mapping[str, str] | None = None, + params: "Mapping[str, str] | None" = None, ) -> LatchResponse: self.assertEqual(method, "DELETE") From a5cfa5d9d36fbda10ca287b9c6214873f9776a16 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 26 Feb 2025 09:28:55 +0100 Subject: [PATCH 09/25] Remove testing print --- src/latch_sdk/syncio/pure.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/latch_sdk/syncio/pure.py b/src/latch_sdk/syncio/pure.py index 2fca375..fcf783b 100644 --- a/src/latch_sdk/syncio/pure.py +++ b/src/latch_sdk/syncio/pure.py @@ -75,8 +75,6 @@ def _http( response_data = response.read().decode("utf8") - print(response_data) - return LatchResponse(response_data) finally: conn.close() From 7b433715f6c431ff41ac6c553bdab0fe3a96afe0 Mon Sep 17 00:00:00 2001 From: Alfred Date: Thu, 27 Feb 2025 13:54:47 +0100 Subject: [PATCH 10/25] Added documentation --- README.md | 49 +- docs/source/api/asyncio.rst | 50 ++ docs/source/api/exceptions.rst | 9 + docs/source/api/index.rst | 14 + docs/source/api/models.rst | 9 + docs/source/api/syncio.rst | 60 ++ docs/source/api/utils.rst | 9 + docs/source/cli.rst | 21 + docs/source/conf.py | 60 +- docs/source/getting-started.rst | 118 +++ docs/source/index.rst | 3 + poetry.lock | 88 +- pyproject.toml | 7 +- src/latch_sdk/__init__.py | 34 +- src/latch_sdk/asyncio/__init__.py | 38 +- src/latch_sdk/asyncio/aiohttp.py | 46 +- src/latch_sdk/asyncio/base.py | 99 ++- src/latch_sdk/asyncio/httpx.py | 45 +- src/latch_sdk/cli/__init__.py | 44 +- src/latch_sdk/cli/application.py | 134 +-- src/latch_sdk/cli/instance.py | 124 +++ src/latch_sdk/cli/operation.py | 140 +++ src/latch_sdk/cli/renders.py | 49 +- src/latch_sdk/cli/types.py | 20 + src/latch_sdk/cli/utils.py | 34 +- src/latch_sdk/error.py | 51 -- src/latch_sdk/exceptions.py | 35 +- src/latch_sdk/models.py | 226 ++++- src/latch_sdk/response.py | 89 -- src/latch_sdk/sansio.py | 292 ++++-- src/latch_sdk/syncio/__init__.py | 40 +- src/latch_sdk/syncio/base.py | 99 ++- src/latch_sdk/syncio/httpx.py | 45 +- src/latch_sdk/syncio/pure.py | 51 +- src/latch_sdk/syncio/requests.py | 53 +- src/latch_sdk/utils.py | 62 +- tests/asyncio/__init__.py | 34 +- tests/asyncio/test_aiohttp.py | 35 +- tests/asyncio/test_base.py | 43 +- tests/asyncio/test_httpx.py | 35 +- tests/factory.py | 50 +- tests/syncio/__init__.py | 34 +- tests/syncio/test_base.py | 43 +- tests/syncio/test_httpx.py | 35 +- tests/syncio/test_pure.py | 40 +- tests/syncio/test_requests.py | 35 +- tests/test_exceptions.py | 35 +- tests/test_sansio.py | 1366 +++++++++++------------------ tests/test_utils.py | 35 +- 49 files changed, 2426 insertions(+), 1741 deletions(-) create mode 100644 docs/source/api/asyncio.rst create mode 100644 docs/source/api/exceptions.rst create mode 100644 docs/source/api/index.rst create mode 100644 docs/source/api/models.rst create mode 100644 docs/source/api/syncio.rst create mode 100644 docs/source/api/utils.rst create mode 100644 docs/source/cli.rst create mode 100644 docs/source/getting-started.rst create mode 100644 src/latch_sdk/cli/instance.py create mode 100644 src/latch_sdk/cli/operation.py create mode 100644 src/latch_sdk/cli/types.py delete mode 100644 src/latch_sdk/error.py delete mode 100644 src/latch_sdk/response.py diff --git a/README.md b/README.md index db5f432..7c0cd04 100644 --- a/README.md +++ b/README.md @@ -14,38 +14,37 @@ * Install "latch-sdk-telefonica" -``` - pip install latch-sdk-telefonica -``` -* Import "latch" module. -``` - import latch_sdk.latch -``` + ```bash + pip install latch-sdk-telefonica + ``` + +* Import "LatchSDK" class. + ```python + from latch_sdk.syncio import LatchSDK + ``` + +* Import one of Latch core classes (read the docs). + + ```python + from latch_sdk.syncio.pure import Latch + ``` * Create a Latch object with the "Application ID" and "Secret" previously obtained. -``` - api = latch.Latch("APP_ID_HERE", "SECRET_KEY_HERE") -``` -* Optional settings: -``` - latch.Latch.set_proxy("PROXY_HOST_HERE", port) -``` + ```python + api = LatchSDK(Latch("APP_ID_HERE", "SECRET_KEY_HERE")) + ``` * Call to Latch Server. Pairing will return an account id that you should store for future api calls -``` - response = api.pair("PAIRING_CODE_HERE") - response = api.status("ACCOUNT_ID_HERE") - response = api.unpair("ACCOUNT_ID_HERE") -``` -* After every API call, get Latch response data and errors and handle them. -``` - responseData = response.get_data() - responseError = response.get_error() + ```python + account_id = api.pair("PAIRING_CODE_HERE") + status = api.status(account_id) + assert api.unpair(account_id) is True ``` + ## USING PYTHON SDK FOR WEB3 SERVICES ## For using the Python SDK within an Web3 service, you must complain with the following: @@ -70,9 +69,9 @@ The two additional parameters are: * Call to Latch Server for pairing as usual, but with the newly methods: ``` python - api = latch.Latch(APP_ID, SECRET_KEY) + api = LatchSDK(Latch("APP_ID_HERE", "SECRET_KEY_HERE")) # PAIR - response = api.pair(pairing_code, WEB3WALLET, WEB3SIGNATURE) + account_id = api.pair(pairing_code, WEB3WALLET, WEB3SIGNATURE) ``` diff --git a/docs/source/api/asyncio.rst b/docs/source/api/asyncio.rst new file mode 100644 index 0000000..eb6dae6 --- /dev/null +++ b/docs/source/api/asyncio.rst @@ -0,0 +1,50 @@ +================ +Asynchronous SDK +================ + + +.. rubric:: **Module:** `latch_sdk.asyncio` + +.. automodule:: latch_sdk.asyncio + :members: + :imported-members: + + +.. _api-async-cores: + +----------- +Latch cores +----------- + +.................. +Base abstract core +.................. + + +.. rubric:: **Module:** `latch_sdk.asyncio.base` + +.. autoclass:: latch_sdk.asyncio.base.BaseLatch + :members: + :show-inheritance: + + +..................... +Using aiohttp library +..................... + +.. rubric:: **Module:** `latch_sdk.asyncio.aiohttp` + + +.. automodule:: latch_sdk.asyncio.aiohttp + :members: + :show-inheritance: + +................... +Using httpx library +................... + +.. rubric:: **Module:** `latch_sdk.asyncio.httpx` + +.. automodule:: latch_sdk.asyncio.httpx + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/api/exceptions.rst b/docs/source/api/exceptions.rst new file mode 100644 index 0000000..9a54e47 --- /dev/null +++ b/docs/source/api/exceptions.rst @@ -0,0 +1,9 @@ +========== +Exceptions +========== + + +.. rubric:: **Module:** `latch_sdk.exceptions` + +.. automodule:: latch_sdk.exceptions + :members: diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 0000000..f07b75c --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,14 @@ +============= +API reference +============= + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + syncio + asyncio + models + exceptions + utils \ No newline at end of file diff --git a/docs/source/api/models.rst b/docs/source/api/models.rst new file mode 100644 index 0000000..9a98a59 --- /dev/null +++ b/docs/source/api/models.rst @@ -0,0 +1,9 @@ +====== +Models +====== + + +.. rubric:: **Module:** `latch_sdk.models` + +.. automodule:: latch_sdk.models + :members: diff --git a/docs/source/api/syncio.rst b/docs/source/api/syncio.rst new file mode 100644 index 0000000..9538bf7 --- /dev/null +++ b/docs/source/api/syncio.rst @@ -0,0 +1,60 @@ +=============== +Synchronous SDK +=============== + + +.. rubric:: **Module:** `latch_sdk.syncio` + +.. automodule:: latch_sdk.syncio + :members: + :imported-members: + + +.. _api-sync-cores: + +----------- +Latch cores +----------- + +.................. +Base abstract core +.................. + + +.. rubric:: **Module:** `latch_sdk.syncio.base` + +.. autoclass:: latch_sdk.syncio.base.BaseLatch + :members: + :show-inheritance: + + +...................... +Using standard library +...................... + +.. rubric:: **Module:** `latch_sdk.syncio.pure` + +.. automodule:: latch_sdk.syncio.pure + :members: + :show-inheritance: + +...................... +Using requests library +...................... + +.. rubric:: **Module:** `latch_sdk.syncio.requests` + + +.. automodule:: latch_sdk.syncio.requests + :members: + :show-inheritance: + +................... +Using httpx library +................... + +.. rubric:: **Module:** `latch_sdk.syncio.httpx` + +.. automodule:: latch_sdk.syncio.httpx + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/api/utils.rst b/docs/source/api/utils.rst new file mode 100644 index 0000000..11ac657 --- /dev/null +++ b/docs/source/api/utils.rst @@ -0,0 +1,9 @@ +===== +Utils +===== + + +.. rubric:: **Module:** `latch_sdk.utils` + +.. automodule:: latch_sdk.utils + :members: diff --git a/docs/source/cli.rst b/docs/source/cli.rst new file mode 100644 index 0000000..799b882 --- /dev/null +++ b/docs/source/cli.rst @@ -0,0 +1,21 @@ +================================== +Command-line interface application +================================== + + +Latch SDK provides a command-line interface application to manage your Latch applications. + +.. note:: + + In order to be able to use the command-lina application an extra requirements must be installed. + + .. code-block:: bash + + $ pip install latch-sdk-telefonica[cli] + +.. contents:: + :backlinks: none + +.. click:: latch_sdk.cli:cli + :prog: latchcli + :nested: full diff --git a/docs/source/conf.py b/docs/source/conf.py index a97327e..b07c0c7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,15 +6,23 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +from datetime import date +from importlib import metadata + project = "Latch SDK for Python" -copyright = "2025, Alfred Santacatalina" -author = "Alfred Santacatalina" -release = "3.0.0" +copyright = f"{date.today().year}, Telefónica Innovación Digital" +author = "Telefónica Innovación Digital" +release = metadata.version("latch-sdk-telefonica") # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = [] +extensions = [ + "sphinx.ext.autodoc", + # "sphinx_autodoc_typehints", + "sphinx_click", + "sphinx.ext.intersphinx", +] templates_path = ["_templates"] exclude_patterns = [] @@ -25,3 +33,47 @@ html_theme = "alabaster" html_static_path = ["_static"] + +html_theme_options = { + "page_width": "1100px", + "sidebar_width": "350px", + "github_repo": "Telefonica/latch-sdk-python", + "github_banner": True, +} + +# -- Autodoc config ----------------------------------------------- + +autodoc_typehints = "description" + +autodoc_type_aliases = {"Command": "click.Command"} + +autodoc_member_order = "groupwise" + +add_module_names = True + +set_type_checking_flag = True + +python_use_unqualified_type_names = True + +autodoc_default_options = { + "members": "", + "member-order": "bysource", + "undoc-members": True, +} + + +# -- Intersphinx config ----------------------------------------------- + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "click": ("https://click.palletsprojects.com/en/latest/", None), + "requests": ("https://requests.readthedocs.io/en/latest/", None), + # "httpx": ("https://www.python-httpx.org/", None), # No object.inv on httpx docs + "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), +} + + +# -- github config ------------------------------------------- + +github_username = "Telefonica" +github_repository = "latch-sdk-telefonica" diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst new file mode 100644 index 0000000..ae62e06 --- /dev/null +++ b/docs/source/getting-started.rst @@ -0,0 +1,118 @@ +=============== +Getting started +=============== + +------------ +Installation +------------ + +.. code-block:: bash + + $ pip install latch-sdk-telefonica + +......................... +Extra requirements groups +......................... + +requests +======== + +This group provides the requirements to use `requests `_ +library as http backend. It allows you to use :class:`latch_sdk.syncio.requests.Latch` core class. + +.. code-block:: bash + + $ pip install latch-sdk-telefonica[requests] + +httpx +===== + +This group provides the requirements to use `httpx `_ +library as http backend. It allows you to use :class:`latch_sdk.syncio.httpx.Latch` core class and +its asynchronous version the core class :class:`latch_sdk.asyncio.httpx.Latch`. + +.. code-block:: bash + + $ pip install latch-sdk-telefonica[httpx] + + +aiohttp +======= + +This group provides the requirements to use `aiohttp `_ +library as http backend. It allows you to use :class:`latch_sdk.syncio.aiohttp.Latch` core class. + +.. code-block:: bash + + $ pip install latch-sdk-telefonica[aiohttp] + + +cli +=== + +This group provides the requirements to use the :doc:`command-line interface application `. + +.. code-block:: bash + + $ pip install latch-sdk-telefonica[cli] + +.. tip:: + + It is possible to install several extra requirements groups joining them with a colon: + + + .. code-block:: bash + + $ pip install latch-sdk-telefonica[cli,aiohttp] + + +---------- +How to use +---------- + +........................ +Synchronous environments +........................ + +On synchronous environments you must use the synchronous :class:`~latch_sdk.syncio.LatchSDK` +class and one of synchronous Latch core classes: :class:`latch_sdk.syncio.pure.Latch`, +:class:`latch_sdk.syncio.requests.Latch` or :class:`latch_sdk.syncio.httpx.Latch`. + +The Latch core class must be instanced using application identifier and secret, and it +must used to instance the :class:`~latch_sdk.syncio.LatchSDK`. + +.. code-block:: python + + from latch_sdk.syncio import LatchSDK + from latch_sdk.syncio.pure import Latch + + latch = LatchSDK(Latch(app_id=MY_APP_ID, secret=MY_SECRET)) + + status = latch.status(MY_ACCOUNT_ID) + + +......................... +Asynchronous environments +......................... + +On asynchronous environments you must use the asynchronous :class:`~latch_sdk.asyncio.LatchSDK` +class and one of asynchronous Latch core classes: :class:`latch_sdk.asyncio.aiohttp.Latch`, +or :class:`latch_sdk.asyncio.httpx.Latch`. + +The Latch core class must be instanced using application identifier and secret, and it +must used to instance the :class:`~latch_sdk.asyncio.LatchSDK`. + +.. code-block:: python + + import asyncio + + from latch_sdk.asyncio import LatchSDK + from latch_sdk.asyncio.pure import Latch + + async main(): + latch = LatchSDK(Latch(app_id=MY_APP_ID, secret=MY_SECRET)) + + status = await latch.status(MY_ACCOUNT_ID) + + + asyncio.run(main()) \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index fea6d0a..2966bde 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,3 +11,6 @@ Latch SDK for Python documentation :maxdepth: 2 :caption: Contents: + getting-started + api/index + cli \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 43dd771..f814571 100644 --- a/poetry.lock +++ b/poetry.lock @@ -153,7 +153,6 @@ description = "A light, configurable Sphinx theme" optional = false python-versions = ">=3.10" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, @@ -240,7 +239,6 @@ description = "Internationalization utilities" optional = false python-versions = ">=3.8" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, @@ -260,7 +258,7 @@ files = [ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] -markers = {main = "extra == \"requests\" or extra == \"httpx\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} +markers = {main = "extra == \"requests\" or extra == \"httpx\""} [[package]] name = "charset-normalizer" @@ -363,20 +361,20 @@ files = [ {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] -markers = {main = "extra == \"requests\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} +markers = {main = "extra == \"requests\""} [[package]] name = "click" version = "8.1.8" description = "Composable command line interface toolkit" -optional = true +optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "extra == \"cli\"" +groups = ["main", "docs"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] +markers = {main = "extra == \"cli\""} [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} @@ -392,7 +390,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "extra == \"cli\" and platform_system == \"Windows\"", dev = "sys_platform == \"win32\"", docs = "python_version >= \"3.11\" and sys_platform == \"win32\""} +markers = {main = "extra == \"cli\" and platform_system == \"Windows\"", dev = "sys_platform == \"win32\"", docs = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -480,7 +478,6 @@ description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -713,7 +710,7 @@ files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] -markers = {main = "extra == \"requests\" or extra == \"aiohttp\" or extra == \"httpx\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} +markers = {main = "extra == \"requests\" or extra == \"aiohttp\" or extra == \"httpx\""} [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] @@ -725,7 +722,6 @@ description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -766,7 +762,6 @@ description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, @@ -785,7 +780,6 @@ description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1045,7 +1039,18 @@ files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] -markers = {docs = "python_version >= \"3.11\""} + +[[package]] +name = "parametrize" +version = "0.1.1" +description = "Drop-in @pytest.mark.parametrize replacement working with unittest.TestCase" +optional = false +python-versions = ">=3.6.2,<4.0.0" +groups = ["dev"] +files = [ + {file = "parametrize-0.1.1-py3-none-any.whl", hash = "sha256:618fc00d15a03df7177691e83e59aeb976b20c410ce39af5063d1839a4673645"}, + {file = "parametrize-0.1.1.tar.gz", hash = "sha256:d7ac0f61b781d1eadfa81d9e57ea80d5e184078e1976b7bb052ab682d9ef35de"}, +] [[package]] name = "pluggy" @@ -1203,7 +1208,6 @@ description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -1265,7 +1269,7 @@ files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] -markers = {main = "extra == \"requests\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} +markers = {main = "extra == \"requests\""} [package.dependencies] certifi = ">=2017.4.17" @@ -1284,7 +1288,6 @@ description = "Manipulate well-formed Roman numerals" optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "roman_numerals_py-3.0.0-py3-none-any.whl", hash = "sha256:a1421ce66b3eab7e8735065458de3fa5c4a46263d50f9f4ac8f0e5e7701dd125"}, {file = "roman_numerals_py-3.0.0.tar.gz", hash = "sha256:91199c4373658c03d87d9fe004f4a5120a20f6cb192be745c2377cce274ef41c"}, @@ -1342,7 +1345,6 @@ description = "This package provides 29 stemmers for 28 languages generated from optional = false python-versions = "*" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, @@ -1355,7 +1357,6 @@ description = "Python documentation generator" optional = false python-versions = ">=3.11" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "sphinx-8.2.0-py3-none-any.whl", hash = "sha256:3c0a40ff71ace28b316bde7387d93b9249a3688c202181519689b66d5d0aed53"}, {file = "sphinx-8.2.0.tar.gz", hash = "sha256:5b0067853d6e97f3fa87563e3404ebd008fce03525b55b25da90706764da6215"}, @@ -1385,6 +1386,43 @@ docs = ["sphinxcontrib-websupport"] lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.394)", "pytest (>=8.0)", "ruff (==0.9.6)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250107)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.1.0" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +optional = false +python-versions = ">=3.11" +groups = ["docs"] +markers = "python_full_version > \"3.11.0\"" +files = [ + {file = "sphinx_autodoc_typehints-3.1.0-py3-none-any.whl", hash = "sha256:67bdee7e27ba943976ce92ebc5647a976a7a08f9f689a826c54617b96a423913"}, + {file = "sphinx_autodoc_typehints-3.1.0.tar.gz", hash = "sha256:a6b7b0b6df0a380783ce5b29150c2d30352746f027a3e294d37183995d3f23ed"}, +] + +[package.dependencies] +sphinx = ">=8.2" + +[package.extras] +docs = ["furo (>=2024.8.6)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.12)", "defusedxml (>=0.7.1)", "diff-cover (>=9.2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "sphobjinv (>=2.3.1.2)", "typing-extensions (>=4.12.2)"] + +[[package]] +name = "sphinx-click" +version = "6.0.0" +description = "Sphinx extension that automatically documents click applications" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "sphinx_click-6.0.0-py3-none-any.whl", hash = "sha256:1e0a3c83bcb7c55497751b19d07ebe56b5d7b85eb76dd399cf9061b497adc317"}, + {file = "sphinx_click-6.0.0.tar.gz", hash = "sha256:f5d664321dc0c6622ff019f1e1c84e58ce0cecfddeb510e004cf60c2a3ab465b"}, +] + +[package.dependencies] +click = ">=8.0" +docutils = "*" +sphinx = ">=4.0" + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" @@ -1392,7 +1430,6 @@ description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, @@ -1410,7 +1447,6 @@ description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, @@ -1428,7 +1464,6 @@ description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML h optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, @@ -1446,7 +1481,6 @@ description = "A sphinx extension which renders display math in HTML via JavaScr optional = false python-versions = ">=3.5" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -1462,7 +1496,6 @@ description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp d optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, @@ -1480,7 +1513,6 @@ description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs optional = false python-versions = ">=3.9" groups = ["docs"] -markers = "python_version >= \"3.11\"" files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, @@ -1572,7 +1604,7 @@ files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] -markers = {main = "extra == \"requests\"", docs = "extra == \"requests\" or python_version >= \"3.11\""} +markers = {main = "extra == \"requests\""} [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] @@ -1686,5 +1718,5 @@ requests = ["requests"] [metadata] lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "7452b19f16538d028d56e04101246c3c10d5e4b6a135818292e560bcaa46f020" +python-versions = ">=3.9,<4.0.0" +content-hash = "c29ca678ba2c48738c703f0e8090c71359c0e0a03045bf5b6336bf768595ce79" diff --git a/pyproject.toml b/pyproject.toml index 23258b1..3af68c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] description = "Latch SDK for Pyhton" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.9,<4.0.0" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License (GPL)", @@ -27,7 +27,7 @@ aiohttp = ["aiohttp (>=3.11.13,<4.0.0)"] httpx = ["httpx (>=0.28.1,<0.29.0)"] [project.scripts] -latchcli = "latch_sdk.cli:latch_sdk" +latchcli = "latch_sdk.cli:cli" [tool.poetry] requires-poetry = ">=2.0" @@ -45,10 +45,13 @@ ruff = "^0.9.6" flake8 = "^7.1.2" pytest-cov = "^6.0.0" types-requests = "^2.32.0.20241016" +parametrize = "^0.1.1" [tool.poetry.group.docs.dependencies] sphinx = {version = "^8.2.0", python = ">=3.11"} +sphinx-click = "^6.0.0" +sphinx-autodoc-typehints = {version = "^3.1.0", python = ">3.11"} [build-system] requires = ["poetry-core"] diff --git a/src/latch_sdk/__init__.py b/src/latch_sdk/__init__.py index 76d921a..a1a351c 100644 --- a/src/latch_sdk/__init__.py +++ b/src/latch_sdk/__init__.py @@ -1,18 +1,16 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/src/latch_sdk/asyncio/__init__.py b/src/latch_sdk/asyncio/__init__.py index 8b09dce..fb5452f 100644 --- a/src/latch_sdk/asyncio/__init__.py +++ b/src/latch_sdk/asyncio/__init__.py @@ -1,20 +1,28 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +Asynchronous LatchSDK class must be used as interface to Latch services on +asynchronous developments environments. It defines a easy to use API for +Latch operations. -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +It requires a core Latch instance which is responsible to make requests +and parse reponses from Latch services. You must choose :ref:`the core ` what +fits better to your developement: a core pawered by `aiohttp `_ +library or a core powered by `httpx `_ library. """ from .base import LatchSDK diff --git a/src/latch_sdk/asyncio/aiohttp.py b/src/latch_sdk/asyncio/aiohttp.py index 9257a85..0add15c 100644 --- a/src/latch_sdk/asyncio/aiohttp.py +++ b/src/latch_sdk/asyncio/aiohttp.py @@ -1,24 +1,34 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + """ -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +This module implements the core Latch class to be used with `aiohttp `_ +library. By default, the required packages to use this module are no installed. You must +install the extra requirements `aiohttp` to be able to use it: -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. +.. code-block:: bash -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. + $ pip install latch-sdk-telefonica[aiohttp] -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +.. warning:: If extra requirements are no satisfied an error will rise on module import. """ from .base import BaseLatch -from ..response import LatchResponse +from ..models import Response try: from aiohttp import ClientSession @@ -37,6 +47,10 @@ class Latch(BaseLatch): + """ + Latch core class using asynchronous aiohttp library. + """ + _session: ClientSession def _reconfigure_session(self) -> None: @@ -54,7 +68,7 @@ async def _http( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: """ HTTP Request to the specified API endpoint """ @@ -62,4 +76,4 @@ async def _http( method, self.build_url(path), data=params, headers=headers ) as response: response.raise_for_status() - return LatchResponse(await response.json()) + return Response(await response.json()) diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index 5e5721d..d0a56f5 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -1,31 +1,29 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - -from typing import TYPE_CHECKING, Awaitable, Callable - -from ..response import LatchResponse +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +from typing import TYPE_CHECKING, Any, Awaitable, Callable + +from ..models import Response from ..sansio import ( LatchSansIO, LatchSDKSansIO, P, TFactory, - TLatch, TReturnType, response_add_instance, response_history, @@ -36,56 +34,57 @@ response_pair, response_status, ) +from ..utils import wraps_and_replace_return if TYPE_CHECKING: # pragma: no cover from typing import Concatenate -class BaseLatch(LatchSansIO[Awaitable[LatchResponse]]): +class BaseLatch(LatchSansIO[Awaitable[Response]]): pass def wrap_method( factory: "TFactory[TReturnType]", - meth: "Callable[Concatenate[TLatch, P], Awaitable[LatchResponse]]", + meth: "Callable[Concatenate[Any, P], Any]", ) -> "Callable[Concatenate[LatchSDK, P], Awaitable[TReturnType]]": + """ + Wrap an action and response processor in a single method. + """ + + @wraps_and_replace_return(meth, factory.__annotations__["return"]) async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) - wrapper.__doc__ = meth.__doc__ - wrapper.__name__ = meth.__name__ - return wrapper -class LatchSDK(LatchSDKSansIO[Awaitable[LatchResponse]]): - pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] +class LatchSDK(LatchSDKSansIO[Awaitable[Response]]): + """ + Latch SDK asynchronous main class. + """ + + pair = wrap_method(response_pair, BaseLatch.pair) pair_with_id = wrap_method( response_pair, - BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] + BaseLatch.pair_with_id, ) - unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] + unpair = wrap_method(response_no_error, BaseLatch.unpair) - status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] - - operation_status = wrap_method( - response_status, - BaseLatch.operation_status, # type: ignore[arg-type, type-var] - ) + status = wrap_method(response_status, BaseLatch.status) - lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] - unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] + lock = wrap_method(response_no_error, BaseLatch.lock) + unlock = wrap_method(response_no_error, BaseLatch.unlock) - create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] - update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] - delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] - get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] + history = wrap_method(response_history, BaseLatch.history) - get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] - instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] - create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] - update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] - delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] + operation_list = wrap_method(response_operation_list, BaseLatch.operation_list) + operation_create = wrap_method(response_operation, BaseLatch.operation_create) + operation_update = wrap_method(response_no_error, BaseLatch.operation_update) + operation_remove = wrap_method(response_no_error, BaseLatch.operation_remove) - history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] + instance_list = wrap_method(response_instance_list, BaseLatch.instance_list) + instance_create = wrap_method(response_add_instance, BaseLatch.instance_create) + instance_update = wrap_method(response_no_error, BaseLatch.instance_update) + instance_remove = wrap_method(response_no_error, BaseLatch.instance_remove) diff --git a/src/latch_sdk/asyncio/httpx.py b/src/latch_sdk/asyncio/httpx.py index 6819c32..3cda389 100644 --- a/src/latch_sdk/asyncio/httpx.py +++ b/src/latch_sdk/asyncio/httpx.py @@ -1,20 +1,29 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +This module implements the core Latch class to be used with `httpx `_ +library. By default, the required packages to use this module are no installed. You must +install the extra requirements `httpx` to be able to use it: -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. +.. code-block:: bash -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. + $ pip install latch-sdk-telefonica[httpx] -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +.. warning:: If extra requirements are no satisfied an error will rise on module import. """ try: @@ -28,7 +37,7 @@ raise from .base import BaseLatch -from ..response import LatchResponse +from ..models import Response try: from collections.abc import Mapping @@ -37,6 +46,10 @@ class Latch(BaseLatch): + """ + Latch core class using asynchronous httpx library. + """ + _session: AsyncClient def _reconfigure_session(self) -> None: @@ -54,7 +67,7 @@ async def _http( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: """ HTTP Request to the specified API endpoint """ @@ -68,4 +81,4 @@ async def _http( response.raise_for_status() - return LatchResponse(response.json()) + return Response(response.json()) diff --git a/src/latch_sdk/cli/__init__.py b/src/latch_sdk/cli/__init__.py index 65cbd0e..ece3dc1 100644 --- a/src/latch_sdk/cli/__init__.py +++ b/src/latch_sdk/cli/__init__.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" import click @@ -25,6 +24,7 @@ @click.group(name="LatchSDK") +@click.version_option(package_name="latch-sdk-telefonica") @click.option( "--app-id", "-a", @@ -44,12 +44,16 @@ show_envvar=True, ) @click.pass_context -def latch_sdk(ctx: click.Context, app_id: str, secret: str): +def cli(ctx: click.Context, app_id: str, secret: str): + """ + Latch command-line application + """ + ctx.obj = LatchSDK(Latch(app_id, secret)) -latch_sdk.add_command(application) +cli.add_command(application) if __name__ == "__main__": - latch_sdk() + cli() diff --git a/src/latch_sdk/cli/application.py b/src/latch_sdk/cli/application.py index b5488d0..bf7159c 100644 --- a/src/latch_sdk/cli/application.py +++ b/src/latch_sdk/cli/application.py @@ -1,30 +1,30 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from datetime import datetime import click +from .instance import instance +from .operation import operation from .renders import ( render_account, render_history_response, - render_operations, render_status, ) from .utils import pass_latch_sdk @@ -33,7 +33,11 @@ @click.group def application(): - pass + """Application actions""" + + +application.add_command(operation) +application.add_command(instance) @application.command() @@ -97,46 +101,12 @@ def unpair( @application.command() @click.argument("ACCOUNT_ID", type=str, required=True) -@click.option("--nootp", default=False, is_flag=True, required=False, help="Avoid OTP") -@click.option( - "--silent", - default=False, - is_flag=True, - required=False, - help="Do not push notification", -) -@pass_latch_sdk -def status(latch: LatchSDK, account_id: str, nootp: bool, silent: bool): - """ - Get latch status - """ - status = latch.status(account_id, nootp=nootp, silent=silent) - - render_status(status) - - -@application.command(name="operation-list") @click.option( "--operation-id", "-o", type=str, required=False, help="Operation identifier" ) -@pass_latch_sdk -def operation_list(latch: LatchSDK, operation_id: str | None): - operations = latch.get_operations(operation_id=operation_id) - - render_operations(operations) - - -@application.group(name="operation") -@click.option("--account-id", "-a", type=str, required=True, help="Account identifier") @click.option( - "--operation-id", "-o", type=str, required=True, help="Operation identifier" + "--instance-id", "-i", type=str, required=False, help="Instances identifier" ) -@click.pass_context -def operation(ctx: click.Context, account_id: str, operation_id: str): - ctx.obj = {"account_id": account_id, "operation_id": operation_id} - - -@operation.command(name="status") @click.option("--nootp", default=False, is_flag=True, required=False, help="Avoid OTP") @click.option( "--silent", @@ -145,11 +115,24 @@ def operation(ctx: click.Context, account_id: str, operation_id: str): required=False, help="Do not push notification", ) -@click.pass_context @pass_latch_sdk -def operation_status(latch: LatchSDK, ctx: click.Context, nootp: bool, silent: bool): - status = latch.operation_status( - ctx.obj["account_id"], ctx.obj["operation_id"], nootp=nootp, silent=silent +def status( + latch: LatchSDK, + account_id: str, + operation_id: "str | None", + instance_id: "str | None", + nootp: bool, + silent: bool, +): + """ + Get latch status + """ + status = latch.status( + account_id, + operation_id=operation_id, + instance_id=instance_id, + nootp=nootp, + silent=silent, ) render_status(status) @@ -160,9 +143,22 @@ def operation_status(latch: LatchSDK, ctx: click.Context, nootp: bool, silent: b @click.option( "--operation-id", "-o", type=str, required=False, help="Operation identifier" ) +@click.option( + "--instance-id", "-i", type=str, required=False, help="Instances identifier" +) @pass_latch_sdk -def lock(latch: LatchSDK, account_id: str, operation_id: str | None): - latch.lock(account_id, operation_id=operation_id) +def lock( + latch: LatchSDK, + account_id: str, + operation_id: "str | None", + instance_id: "str | None", +): + """Lock a latch""" + latch.lock( + account_id, + operation_id=operation_id, + instance_id=instance_id, + ) click.secho("Latch locked", bg="red") @@ -172,9 +168,22 @@ def lock(latch: LatchSDK, account_id: str, operation_id: str | None): @click.option( "--operation-id", "-o", type=str, required=False, help="Operation identifier" ) +@click.option( + "--instance-id", "-i", type=str, required=False, help="Instances identifier" +) @pass_latch_sdk -def unlock(latch: LatchSDK, account_id: str, operation_id: str | None): - latch.unlock(account_id, operation_id=operation_id) +def unlock( + latch: LatchSDK, + account_id: str, + operation_id: "str | None", + instance_id: "str | None", +): + """Unlock a latch""" + latch.unlock( + account_id, + operation_id=operation_id, + instance_id=instance_id, + ) click.secho("Latch unlocked", bg="green") @@ -191,6 +200,7 @@ def unlock(latch: LatchSDK, account_id: str, operation_id: str | None): def history( latch: LatchSDK, account_id: str, from_dt: datetime | None, to_dt: datetime | None ): + """Show latch actions history""" history = latch.history(account_id, from_dt=from_dt, to_dt=to_dt) render_history_response(history) diff --git a/src/latch_sdk/cli/instance.py b/src/latch_sdk/cli/instance.py new file mode 100644 index 0000000..5381921 --- /dev/null +++ b/src/latch_sdk/cli/instance.py @@ -0,0 +1,124 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +import click + +from .renders import render_instances +from .types import EnumChoice +from .utils import pass_latch_sdk +from ..models import ExtraFeature +from ..syncio import LatchSDK + + +@click.group() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--operation-id", "-o", type=str, required=False, help="Operation identifier" +) +@click.pass_context +def instance(ctx: click.Context, account_id: str, operation_id: "str | None"): + """Instances actions""" + ctx.obj = {"account_id": account_id, "operation_id": operation_id} + + +@instance.command(name="list") +@click.pass_context +@pass_latch_sdk +def lst(latch: LatchSDK, ctx: click.Context): + """Print available instances list""" + instances = latch.instance_list( + ctx.obj["account_id"], operation_id=ctx.obj["operation_id"] + ) + + render_instances(instances) + + +@instance.command(name="create") +@click.option("--name", "-n", type=str, required=True, help="Intance name") +@click.pass_context +@pass_latch_sdk +def create( + latch: LatchSDK, + ctx: click.Context, + name: str, +): + """Create a new instance""" + instance_id = latch.instance_create( + name, ctx.obj["account_id"], operation_id=ctx.obj["operation_id"] + ) + + click.secho(f"Created new instance with id: {instance_id}", bg="green") + + +@instance.command(name="update") +@click.argument("INSTANCE_ID", type=str, required=True) +@click.option("--name", "-n", type=str, required=False, help="Operation name") +@click.option( + "--two-factor", + "-t", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + help="Two factor feature", +) +@click.option( + "--lock-on-request", + "-l", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + help="Lock on request feature", +) +@click.pass_context +@pass_latch_sdk +def update( + latch: LatchSDK, + ctx: click.Context, + instance_id: str, + name: str | None, + two_factor: ExtraFeature | None, + lock_on_request: ExtraFeature | None, +): + """Update a given instance""" + latch.instance_update( + instance_id, + ctx.obj["account_id"], + operation_id=ctx.obj["operation_id"], + name=name, + two_factor=two_factor, + lock_on_request=lock_on_request, + ) + + click.secho("Operation updated", bg="green") + + +@instance.command(name="remove") +@click.argument("INSTANCE_ID", type=str, required=True) +@click.pass_context +@pass_latch_sdk +def remove( + latch: LatchSDK, + ctx: click.Context, + instance_id: str, +): + """Remove a given instance""" + latch.instance_remove( + instance_id, + ctx.obj["account_id"], + operation_id=ctx.obj["operation_id"], + ) + + click.secho("Operation removed", bg="green") diff --git a/src/latch_sdk/cli/operation.py b/src/latch_sdk/cli/operation.py new file mode 100644 index 0000000..7ad1e8d --- /dev/null +++ b/src/latch_sdk/cli/operation.py @@ -0,0 +1,140 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +import click + +from .renders import ( + render_operation, + render_operations, +) +from .types import EnumChoice +from .utils import pass_latch_sdk +from ..models import ExtraFeature +from ..syncio import LatchSDK + + +@click.group() +def operation(): + """Operations actions""" + + +@operation.command(name="list") +@click.option( + "--parent-id", "-p", type=str, required=False, help="Parent operation identifier" +) +@pass_latch_sdk +def lst(latch: LatchSDK, parent_id: "str | None"): + """Print available operations list""" + operations = latch.operation_list(parent_id=parent_id) + + render_operations(operations) + + +@operation.command(name="create") +@click.option( + "--parent-id", + "-p", + type=str, + required=True, + help="Parent operation or application identifier", +) +@click.option("--name", "-n", type=str, required=True, help="Operation name") +@click.option( + "--two-factor", + "-t", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + default=ExtraFeature.DISABLED, + help="Two factor feature", +) +@click.option( + "--lock-on-request", + "-l", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + default=ExtraFeature.DISABLED, + help="Lock on request feature", +) +@pass_latch_sdk +def create( + latch: LatchSDK, + parent_id: str, + name: str, + two_factor: ExtraFeature, + lock_on_request: ExtraFeature, +): + """Create a new operation""" + operation = latch.operation_create( + parent_id=parent_id, + name=name, + two_factor=two_factor, + lock_on_request=lock_on_request, + ) + + render_operation(operation) + + +@operation.command(name="update") +@click.argument("OPERATION_ID", type=str, required=True) +@click.option("--name", "-n", type=str, required=False, help="Operation name") +@click.option( + "--two-factor", + "-t", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + help="Two factor feature", +) +@click.option( + "--lock-on-request", + "-l", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + help="Lock on request feature", +) +@pass_latch_sdk +def update( + latch: LatchSDK, + operation_id: str, + name: str | None, + two_factor: ExtraFeature | None, + lock_on_request: ExtraFeature | None, +): + """Update a given operation""" + latch.operation_update( + operation_id=operation_id, + name=name, + two_factor=two_factor, + lock_on_request=lock_on_request, + ) + + click.secho("Operation updated", bg="green") + + +@operation.command(name="remove") +@click.argument("OPERATION_ID", type=str, required=True) +@pass_latch_sdk +def remove( + latch: LatchSDK, + operation_id: str, +): + """Remove a given operation""" + latch.operation_remove( + operation_id=operation_id, + ) + + click.secho("Operation removed", bg="green") diff --git a/src/latch_sdk/cli/renders.py b/src/latch_sdk/cli/renders.py index d5b0a29..055bf7d 100644 --- a/src/latch_sdk/cli/renders.py +++ b/src/latch_sdk/cli/renders.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from typing import Iterable @@ -26,6 +25,7 @@ Client, HistoryEntry, HistoryResponse, + Instance, Operation, Status, ) @@ -135,3 +135,18 @@ def render_history_entry(entry: HistoryEntry, indent=""): click.echo(f"Was: {entry.was}") click.echo(f"What: {entry.what}") click.echo(f"From ip/user-agent: {entry.ip}") + + +def render_instances(instances: Iterable[Instance]): + click.echo("Instances:") + click.echo("-" * 50) + for inst in instances: + render_instance(inst) + click.echo("-" * 50) + + +def render_instance(instance: Instance, indent=""): + click.echo(f"{indent}Name: {instance.name}") + click.echo(f"{indent}Instance ID: {instance.instance_id}") + click.echo(f"{indent}Two factor: {instance.two_factor.value}") + click.echo(f"{indent}Lock on request: {instance.lock_on_request.value}") diff --git a/src/latch_sdk/cli/types.py b/src/latch_sdk/cli/types.py new file mode 100644 index 0000000..0c76a2d --- /dev/null +++ b/src/latch_sdk/cli/types.py @@ -0,0 +1,20 @@ +from enum import Enum +from typing import Any + +import click + + +class EnumChoice(click.Choice): + """Click parameter type based on a given enumeration""" + + def __init__(self, enum: type[Enum], case_sensitive: bool = True) -> None: + super().__init__( + [str(e.value) for e in enum._member_map_.values()], + case_sensitive=case_sensitive, + ) + self.enum = enum + + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> Any: + return self.enum(super().convert(value, param, ctx)) diff --git a/src/latch_sdk/cli/utils.py b/src/latch_sdk/cli/utils.py index 9d39fb0..7a9a0ab 100644 --- a/src/latch_sdk/cli/utils.py +++ b/src/latch_sdk/cli/utils.py @@ -1,24 +1,24 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" import click from ..syncio import LatchSDK +#: Decorator to pass LatchSDK instance to the decorated command pass_latch_sdk = click.make_pass_decorator(LatchSDK) diff --git a/src/latch_sdk/error.py b/src/latch_sdk/error.py deleted file mode 100644 index 756755d..0000000 --- a/src/latch_sdk/error.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -This library offers an API to use Latch in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - -import json -from typing import TypedDict - - -class ErrorData(TypedDict): - code: int - message: str - - -class Error(object): - def __init__(self, json_data: ErrorData): - """ - Constructor - """ - - self.code = json_data["code"] - self.message = json_data["message"] - - def get_code(self) -> int: - return self.code - - def get_message(self) -> str: - return self.message - - def to_json(self) -> ErrorData: - return {"code": self.code, "message": self.message} - - def __repr__(self) -> str: - return json.dumps(self.to_json()) - - def __str__(self) -> str: - return self.__repr__() diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py index 3f798e5..84a413b 100644 --- a/src/latch_sdk/exceptions.py +++ b/src/latch_sdk/exceptions.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + _errors: dict[int, type["BaseLatchException"]] = {} diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index c2d68b9..315e37e 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -1,26 +1,25 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING, Any, Iterable +from typing import TYPE_CHECKING, Any, Iterable, TypedDict if TYPE_CHECKING: # pragma: no cover from typing import Self @@ -28,11 +27,18 @@ @dataclass(frozen=True) class TwoFactor: + #: Token to validate. token: str + + #: When the token was generated. generated: datetime @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + data.update(kwargs) return cls( @@ -43,15 +49,23 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @dataclass(frozen=True) class Status: + #: Operation identifier. operation_id: str #: True means the latch is closed and any action must be blocked. status: bool + + #: Two factor data if it is required. two_factor: "TwoFactor | None" = None + + #: List of descendant operations. operations: "Iterable[Status] | None" = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ data.update(kwargs) return cls( @@ -74,28 +88,52 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": class ExtraFeature(str, Enum): + #: Feature is required. MANDATORY = "MANDATORY" + + #: Feature is optional. OPT_IN = "OPT_IN" + + #: Feature is disabled. DISABLED = "DISABLED" class ExtraFeatureStatus(str, Enum): + #: Feature is required. MANDATORY = "MANDATORY" + + #: Feature is optional. OPT_IN = "OPT_IN" + + #: Feature is disabled. DISABLED = "DISABLED" + #: Current status of feature is ON. ON = "on" + + #: Current status of feature is OFF. OFF = "off" @dataclass(frozen=True) class Operation: + """ + Latch operation + """ + + #: Operation identifier. operation_id: str + #: Operation name name: str + + #: Parent identifier parent_id: "str | None" = None + #: State of `Two factor` feature. two_factor: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED + + #: State of `Lock on request` feature. lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED #: True means the latch is closed and any action must be blocked. @@ -103,6 +141,10 @@ class Operation: @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + data.update(kwargs) return cls( @@ -125,15 +167,28 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @dataclass(frozen=True) class Instance: + """ + Latch instance. + """ + + #: Instance identifier instance_id: str + #: Instance name name: str + #: State of `Two factor` feature. two_factor: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED + + #: State of `Lock on request` feature. lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + data.update(kwargs) return cls( @@ -154,16 +209,37 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @dataclass(frozen=True) class HistoryEntry: + """ + History entry. It describes something that happened. + """ + + #: When event happened. t: datetime + + #: Action name. action: str + + #: What happened. what: str + + #: What was the value before action. was: "Any | None" + + #: What is the new value. value: "Any | None" + + #: Event name name: str + + #: IP or user-agent user make action from. ip: str @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + data.update(kwargs) return cls( @@ -179,25 +255,55 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @dataclass(frozen=True) class Application: + """ + Latch Application information + """ + + #: Application identifier. application_id: str + + #: Application name. name: str + + #: Application description. description: str + + #: URL to application image. image_url: str + + #: Application current state. status: bool + + #: When it was paired. paired_on: datetime + + #: Last time status changed. status_last_modified: datetime + + #: Minutes to close latch after open. 0 means no autoclose. autoclose: int + #: Contact email. contact_mail: "str | None" = None + + #: Contact phone number. contact_phone: "str | None" = None + #: State of `Two factor` feature. two_factor: ExtraFeature = ExtraFeature.DISABLED + + #: State of `Lock on request` feature. lock_on_request: ExtraFeature = ExtraFeature.DISABLED + #: List of descendant operations. operations: "Iterable[Operation] | None" = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + data.update(kwargs) return cls( @@ -236,21 +342,44 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @dataclass(frozen=True) class Client: + """ + Client information + """ + + #: Client platform: Window, Android, iOS, etc... platform: str + + #: Client application version. app: str @dataclass(frozen=True) class HistoryResponse: + """ + Response for history request. + """ + + #: Application information. application: Application + + #: List of client used by user. client_version: Iterable[Client] + + #: Number of history entries. count: int + + #: List of history entries. history: Iterable[HistoryEntry] + #: Last time user has been seen. last_seen: "datetime | None" = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + data.update(kwargs) app_id = next( @@ -270,3 +399,64 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": count=data.get("count", 0), history=[HistoryEntry.build_from_dict(h) for h in data["history"]], ) + + +class ErrorData(TypedDict): + code: int + message: str + + +class Error: + def __init__(self, data: ErrorData): + """ + Error model + """ + + self.code = data["code"] + self.message = data["message"] + + def to_dict(self) -> ErrorData: + return {"code": self.code, "message": self.message} + + def __repr__(self) -> str: + import json + + return json.dumps(self.to_dict()) + + def __str__(self) -> str: + return self.__repr__() + + +class Response: + """ + This class models a response from any of the endpoints in the Latch API. + It consists of a "data" and an "error" elements. Although normally only one of them will be + present, they are not mutually exclusive, since errors can be non fatal, and therefore a response + could have valid information in the data field and at the same time inform of an error. + """ + + def __init__(self, data: "dict[str, Any]"): + """ + :param data: a json string or a dict received from one of the methods of the Latch API + """ + self.data: "dict[str, Any] | None" = None + if "data" in data: + self.data = data["data"] + + self.error: "Error | None" = None + if "error" in data: + self.error = Error(data["error"]) + + def to_dict(self): + """ + :return: a dict with the data and error parts set if they exist + """ + result = {} + + if self.data: + result["data"] = self.data + + if self.error: + result["error"] = self.error.to_dict() + + return result diff --git a/src/latch_sdk/response.py b/src/latch_sdk/response.py deleted file mode 100644 index 505d43a..0000000 --- a/src/latch_sdk/response.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -This library offers an API to use Latch in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - -import json -from typing import Any - -from .error import Error, ErrorData - - -class LatchResponse: - """ - This class models a response from any of the endpoints in the Latch API. - It consists of a "data" and an "error" elements. Although normally only one of them will be - present, they are not mutually exclusive, since errors can be non fatal, and therefore a response - could have valid information in the data field and at the same time inform of an error. - """ - - def __init__(self, data: "str | dict[str, Any]"): - """ - @param $json a json string received from one of the methods of the Latch API - """ - json_object: dict[str, Any] - - if isinstance(data, str): - json_object = json.loads(data) - else: - json_object = data - - self.data: "dict[str, Any] | None" = None - if "data" in json_object: - self.data = json_object["data"] - - self.error: "Error | None" = None - if "error" in json_object: - self.error = Error(json_object["error"]) - - def get_data(self) -> "dict[str, Any] | None": - """ - @return JsonObject the data part of the API response - """ - return self.data - - def set_data(self, data: str): - """ - @param $data the data to include in the API response - """ - self.data = json.loads(data) - - def get_error(self) -> "Error | None": - """ - @return Error the error part of the API response, consisting of an error code and an error message - """ - return self.error - - def set_error(self, error: ErrorData): - """ - @param $error an error to include in the API response - """ - self.error = Error(error) - - def to_json(self): - """ - @return a Json object with the data and error parts set if they exist - """ - json_response = {} - - if self.data: - json_response["data"] = self.data - - if self.error: - json_response["error"] = self.error.to_json() - - return json_response diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index d18ec20..ac0e182 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from abc import ABC, abstractmethod from datetime import datetime, timezone @@ -35,8 +34,7 @@ from urllib.parse import urlparse from .exceptions import ApplicationAlreadyPaired, BaseLatchException -from .models import ExtraFeature, HistoryResponse, Instance, Operation, Status -from .response import LatchResponse +from .models import ExtraFeature, HistoryResponse, Instance, Operation, Response, Status try: from collections.abc import Mapping @@ -55,7 +53,7 @@ ) -TResponse = TypeVar("TResponse", Awaitable[LatchResponse], LatchResponse) +TResponse = TypeVar("TResponse", Awaitable[Response], Response) TReturnType = TypeVar("TReturnType") P = ParamSpec("P") @@ -444,6 +442,13 @@ def pair_with_id( web3_account: "str | None" = None, web3_signature: "str | None" = None, ) -> TResponse: + """ + Pairs the origin provider with a user account (mail). + + :param account_id: The email for the pairing account - only useful in staging + :param web3_account: The Ethereum-based account address to pairing the app. + :param web3_signature: A proof-of-ownership signature with the account address. + """ path = "/".join((self._paths["pair_with_id"], account_id)) if web3_account is None or web3_signature is None: @@ -459,6 +464,13 @@ def pair( web3_account: "str | None" = None, web3_signature: "str | None" = None, ) -> TResponse: + """ + Pairs the token provider with a user account. + + :param token: The token for pairing the app, generated by the Latch mobile app. + :param web3_account: The Ethereum-based account address to pairing the app. + :param web3_signature: A proof-of-ownership signature with the account address. + """ path = "/".join((self._paths["pair"], token)) if web3_account is None or web3_signature is None: @@ -468,21 +480,31 @@ def pair( return self._prepare_http("POST", path, None, params) def status( - self, account_id: str, *, silent: bool = False, nootp: bool = False + self, + account_id: str, + *, + instance_id: "str | None" = None, + operation_id: "str | None" = None, + silent: bool = False, + nootp: bool = False, ) -> TResponse: + """ + Return operation status for a given accountId and operation while + sending some custom data (Like OTP token or a message). + + :param account_id: The account identifier which status is going to be retrieved. + :param instance_id: The instance identifier. + :param operation_id: The operation identifier which status is going to be retrieved. + :param silent: True for not sending lock/unlock push notifications to the mobile devices, false otherwise. + :param nootp: True for not generating a OTP if needed. + """ parts = [self._paths["check_status"], account_id] - if nootp: - parts.append("nootp") - if silent: - parts.append("silent") - - return self._prepare_http("GET", "/".join(parts)) + if operation_id: + parts.extend(("op", operation_id)) - def operation_status( - self, account_id: str, operation_id: str, *, silent=False, nootp=False - ) -> TResponse: - parts = [self._paths["check_status"], account_id, "op", operation_id] + if instance_id: + parts.extend(("i", instance_id)) if nootp: parts.append("nootp") @@ -492,22 +514,59 @@ def operation_status( return self._prepare_http("GET", "/".join(parts)) def unpair(self, account_id: str) -> TResponse: + """ + Unpairs the origin provider with a user account. + + :param account_id: The account identifier. + """ return self._prepare_http("GET", "/".join((self._paths["unpair"], account_id))) - def lock(self, account_id: str, *, operation_id: "str | None" = None) -> TResponse: + def lock( + self, + account_id: str, + *, + instance_id: "str | None" = None, + operation_id: "str | None" = None, + ) -> TResponse: + """ + Locks the operation. + + :param account_id: The account identifier. + :param instance_id: The instance identifier. + :param operation_id: The operation identifier. + """ parts = [self._paths["lock"], account_id] if operation_id is not None: parts.extend(("op", operation_id)) + if instance_id: + parts.extend(("i", instance_id)) + return self._prepare_http("POST", "/".join(parts)) - def unlock(self, account_id: str, *, operation_id: "str | None" = None): + def unlock( + self, + account_id: str, + *, + instance_id: "str | None" = None, + operation_id: "str | None" = None, + ): + """ + Unlocks the operation + + :param account_id: The account identifier + :param instance_id: The instance identifier + :param operation_id: The operation identifier + """ parts = [self._paths["unlock"], account_id] if operation_id is not None: parts.extend(("op", operation_id)) + if instance_id: + parts.extend(("i", instance_id)) + return self._prepare_http("POST", "/".join(parts)) def history( @@ -517,6 +576,13 @@ def history( from_dt: "datetime | None" = None, to_dt: "datetime | None" = None, ): + """ + Get history status + + :param account_id: The account identifier. + :param from_dt: Datetime to start from. + :param to_dt: Datetime limit. + """ from_dt = from_dt or datetime.fromtimestamp(0, tz=timezone.utc) to_dt = to_dt or datetime.now(tz=timezone.utc) @@ -532,7 +598,7 @@ def history( ), ) - def create_operation( + def operation_create( self, parent_id: str, name: str, @@ -540,6 +606,15 @@ def create_operation( two_factor: ExtraFeature = ExtraFeature.DISABLED, lock_on_request: ExtraFeature = ExtraFeature.DISABLED, ): + """ + Add a new operation. + + :param parent_id: identifies the parent of the operation to be created. + :param name: The name of the operation. + :param two_factor: Specifies if the `Two Factor` protection is enabled for this operation. + :param lock_on_request: Specifies if the `Lock latches on status request` feature is disabled, + opt-in or mandatory for this operation. + """ params = { "parentId": parent_id, "name": name, @@ -548,71 +623,86 @@ def create_operation( } return self._prepare_http("PUT", self._paths["operation"], None, params) - def update_operation( + def operation_update( self, operation_id: str, - name: str, *, + name: "str | None" = None, two_factor: "ExtraFeature | None" = None, lock_on_request: "ExtraFeature | None" = None, ): - params = { - "name": name, - } + """ + Update an operation. + + :param operation_id: The operation identifier. + :param name: The name of the operation. + :param two_factor: Specifies if the `Two Factor` protection is enabled for this operation. + :param lock_on_request: Specifies if the `Lock latches on status request` feature is disabled, + opt-in or mandatory for this operation. + """ + params: dict[str, str] = {} + if name: + params["name"] = name if two_factor: params["two_factor"] = two_factor.value if lock_on_request: params["lock_on_request"] = lock_on_request.value + + if len(params) == 0: + raise ValueError("No new data to update") + return self._prepare_http( "POST", "/".join((self._paths["operation"], operation_id)), None, params ) - def delete_operation(self, operation_id: str): + def operation_remove(self, operation_id: str): + """ + Remove an operation. + + :param operation_id: The operation identifier. + """ return self._prepare_http( "DELETE", "/".join((self._paths["operation"], operation_id)) ) - def get_operations(self, *, operation_id: "str | None" = None): + def operation_list(self, *, parent_id: "str | None" = None): + """ + Get a list of operations. + + :param parent_id: To filter by parent operation. + """ parts = [self._paths["operation"]] - if operation_id is not None: - parts.append(operation_id) + if parent_id is not None: + parts.append(parent_id) return self._prepare_http("GET", "/".join(parts)) - def get_instances(self, account_id: str, *, operation_id: "str | None" = None): - parts = [self._paths["instance"], account_id] - - if operation_id is not None: - parts.extend(("op", operation_id)) + def instance_list(self, account_id: str, *, operation_id: "str | None" = None): + """ + Get a list of instances. - return self._prepare_http("GET", "/".join(parts)) + :param account_id: The account identifier. + :param operation_id: The operation identifier. + """ + parts = [self._paths["instance"], account_id] - def instance_status( - self, - instance_id: str, - account_id: str, - operation_id: "str | None" = None, - silent: bool = False, - nootp: bool = False, - ): - parts = [self._paths["check_status"], account_id] if operation_id is not None: parts.extend(("op", operation_id)) - parts.extend(("i", instance_id)) - - if nootp: - parts.append("nootp") - if silent: - parts.append("silent") - return self._prepare_http("GET", "/".join(parts)) - def create_instance( + def instance_create( self, name: str, account_id: str, *, operation_id: "str | None" = None ): + """ + Create an instance. + + :param name: The name of the instance. + :param account_id: The account identifier. + :param operation_id: The operation identifier. + """ # Only one at a time params = {"instances": name} @@ -628,7 +718,7 @@ def create_instance( params, ) - def update_instance( + def instance_update( self, instance_id: str, account_id: str, @@ -638,6 +728,17 @@ def update_instance( two_factor: "ExtraFeature | None" = None, lock_on_request: "ExtraFeature | None" = None, ): + """ + Update an instance. + + :param account_id: The account identifier. + :param instance_id: The instance identifier. + :param operation_id: The operation identifier. + :param name: The name of the instance. + :param two_factor: Specifies if the `Two Factor` protection is enabled for this instance. + :param lock_on_request: Specifies if the `Lock latches on status request` feature is disabled, + opt-in or mandatory for this instance. + """ params: dict[str, str] = {} if name: @@ -666,9 +767,16 @@ def update_instance( params, ) - def delete_instance( + def instance_remove( self, instance_id: str, account_id: str, operation_id: "str | None" = None ): + """ + Remove the instance. + + :param account_id: The account identifier. + :param instance_id: The instance identifier. + :param operation_id: The operation identifier. + """ parts = [self._paths["instance"], account_id] if operation_id is not None: @@ -679,8 +787,7 @@ def delete_instance( return self._prepare_http("DELETE", "/".join(parts)) -TLatch = TypeVar("TLatch", bound=LatchSansIO[LatchResponse]) -TFactory: "TypeAlias" = Callable[[LatchResponse], "TReturnType"] +TFactory: "TypeAlias" = Callable[[Response], "TReturnType"] class LatchSDKSansIO(Generic[TResponse]): @@ -688,14 +795,18 @@ def __init__(self, core: LatchSansIO[TResponse]): self._core: LatchSansIO[TResponse] = core -def check_error(resp: LatchResponse): +def check_error(resp: Response): + """Check whether response contains an error or not and raise an exception if it does""" if not resp.error: return - raise BaseLatchException(resp.error.get_code(), resp.error.get_message()) + raise BaseLatchException(resp.error.code, resp.error.message) -def response_pair(resp: LatchResponse) -> str: +def response_pair(resp: Response) -> str: + """ + Gets accountId from response + """ try: check_error(resp) except ApplicationAlreadyPaired as ex: @@ -708,13 +819,19 @@ def response_pair(resp: LatchResponse) -> str: return cast(str, resp.data["accountId"]) -def response_no_error(resp: LatchResponse) -> Literal[True]: +def response_no_error(resp: Response) -> Literal[True]: + """ + Returns `True` if not error + """ check_error(resp) return True -def response_status(resp: LatchResponse) -> Status: +def response_status(resp: Response) -> Status: + """ + Builds status object from response + """ check_error(resp) assert resp.data is not None, "No error or data" @@ -724,7 +841,10 @@ def response_status(resp: LatchResponse) -> Status: return Status.build_from_dict(status_data, operation_id=op_id) -def response_operation(resp: LatchResponse) -> Operation: +def response_operation(resp: Response) -> Operation: + """ + Builds operation object from response + """ check_error(resp) assert resp.data is not None, "No error or data" @@ -740,7 +860,10 @@ def response_operation(resp: LatchResponse) -> Operation: return Operation.build_from_dict(operation_data, operation_id=op_id) -def response_operation_list(resp: LatchResponse) -> Iterable[Operation]: +def response_operation_list(resp: Response) -> Iterable[Operation]: + """ + Builds operation object list from response + """ check_error(resp) assert resp.data is not None, "No error or data" @@ -751,7 +874,10 @@ def response_operation_list(resp: LatchResponse) -> Iterable[Operation]: ] -def response_instance_list(resp: LatchResponse) -> Iterable[Instance]: +def response_instance_list(resp: Response) -> Iterable[Instance]: + """ + Builds instance object list from response + """ check_error(resp) assert resp.data is not None, "No error or data" @@ -759,7 +885,10 @@ def response_instance_list(resp: LatchResponse) -> Iterable[Instance]: return [Instance.build_from_dict(d, instance_id=i) for i, d in resp.data.items()] -def response_add_instance(resp: LatchResponse) -> str: +def response_add_instance(resp: Response) -> str: + """ + Gets instance identifier from response + """ check_error(resp) assert resp.data is not None, "No error or data" @@ -767,7 +896,10 @@ def response_add_instance(resp: LatchResponse) -> str: return next(iter(resp.data.keys())) -def response_history(resp: LatchResponse) -> HistoryResponse: +def response_history(resp: Response) -> HistoryResponse: + """ + Builds history response object from response + """ check_error(resp) assert resp.data is not None, "No error or data" diff --git a/src/latch_sdk/syncio/__init__.py b/src/latch_sdk/syncio/__init__.py index 8b09dce..32cd5d0 100644 --- a/src/latch_sdk/syncio/__init__.py +++ b/src/latch_sdk/syncio/__init__.py @@ -1,20 +1,30 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. +""" +Synchronous LatchSDK class must be used as interface to Latch services on +synchronous developments environments. It defines a easy to use API for +Latch operations. -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +It requires a core Latch instance which is responsible to make requests +and parse reponses from Latch services. You must choose :ref:`the core ` what +fits better to your developement: a pure python using standard libraries, +a core pawered by `requests `_ +library or a core powered by `httpx `_ library. """ from .base import LatchSDK diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index 3005fcb..d893e1c 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -1,31 +1,29 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" - -from typing import TYPE_CHECKING, Callable - -from ..response import LatchResponse +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +from typing import TYPE_CHECKING, Any, Callable + +from ..models import Response from ..sansio import ( LatchSansIO, LatchSDKSansIO, P, TFactory, - TLatch, TReturnType, response_add_instance, response_history, @@ -36,56 +34,57 @@ response_pair, response_status, ) +from ..utils import wraps_and_replace_return if TYPE_CHECKING: # pragma: no cover from typing import Concatenate -class BaseLatch(LatchSansIO[LatchResponse]): +class BaseLatch(LatchSansIO[Response]): pass def wrap_method( factory: "TFactory[TReturnType]", - meth: "Callable[Concatenate[TLatch, P], LatchResponse]", + meth: "Callable[Concatenate[Any, P], Any]", ) -> "Callable[Concatenate[LatchSDK, P], TReturnType]": + """ + Wrap an action and response processor in a single method. + """ + + @wraps_and_replace_return(meth, factory.__annotations__["return"]) def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) - wrapper.__doc__ = meth.__doc__ - wrapper.__name__ = meth.__name__ - return wrapper -class LatchSDK(LatchSDKSansIO[LatchResponse]): - pair = wrap_method(response_pair, BaseLatch.pair) # type: ignore[arg-type, type-var] +class LatchSDK(LatchSDKSansIO[Response]): + """ + Latch SDK synchronous main class. + """ + + pair = wrap_method(response_pair, BaseLatch.pair) pair_with_id = wrap_method( response_pair, - BaseLatch.pair_with_id, # type: ignore[arg-type, type-var] + BaseLatch.pair_with_id, ) - unpair = wrap_method(response_no_error, BaseLatch.unpair) # type: ignore[arg-type, type-var] + unpair = wrap_method(response_no_error, BaseLatch.unpair) - status = wrap_method(response_status, BaseLatch.status) # type: ignore[arg-type, type-var] - - operation_status = wrap_method( - response_status, - BaseLatch.operation_status, # type: ignore[arg-type, type-var] - ) + status = wrap_method(response_status, BaseLatch.status) - lock = wrap_method(response_no_error, BaseLatch.lock) # type: ignore[arg-type, type-var] - unlock = wrap_method(response_no_error, BaseLatch.unlock) # type: ignore[arg-type, type-var] + lock = wrap_method(response_no_error, BaseLatch.lock) + unlock = wrap_method(response_no_error, BaseLatch.unlock) - create_operation = wrap_method(response_operation, BaseLatch.create_operation) # type: ignore[arg-type, type-var] - update_operation = wrap_method(response_no_error, BaseLatch.update_operation) # type: ignore[arg-type, type-var] - delete_operation = wrap_method(response_no_error, BaseLatch.delete_operation) # type: ignore[arg-type, type-var] - get_operations = wrap_method(response_operation_list, BaseLatch.get_operations) # type: ignore[arg-type, type-var] + history = wrap_method(response_history, BaseLatch.history) - get_instances = wrap_method(response_instance_list, BaseLatch.get_instances) # type: ignore[arg-type, type-var] - instance_status = wrap_method(response_status, BaseLatch.instance_status) # type: ignore[arg-type, type-var] - create_instance = wrap_method(response_add_instance, BaseLatch.create_instance) # type: ignore[arg-type, type-var] - update_instance = wrap_method(response_no_error, BaseLatch.update_instance) # type: ignore[arg-type, type-var] - delete_instance = wrap_method(response_no_error, BaseLatch.delete_instance) # type: ignore[arg-type, type-var] + operation_list = wrap_method(response_operation_list, BaseLatch.operation_list) + operation_create = wrap_method(response_operation, BaseLatch.operation_create) + operation_update = wrap_method(response_no_error, BaseLatch.operation_update) + operation_remove = wrap_method(response_no_error, BaseLatch.operation_remove) - history = wrap_method(response_history, BaseLatch.history) # type: ignore[arg-type, type-var] + instance_list = wrap_method(response_instance_list, BaseLatch.instance_list) + instance_create = wrap_method(response_add_instance, BaseLatch.instance_create) + instance_update = wrap_method(response_no_error, BaseLatch.instance_update) + instance_remove = wrap_method(response_no_error, BaseLatch.instance_remove) diff --git a/src/latch_sdk/syncio/httpx.py b/src/latch_sdk/syncio/httpx.py index 8ec9f27..0d6afd0 100644 --- a/src/latch_sdk/syncio/httpx.py +++ b/src/latch_sdk/syncio/httpx.py @@ -1,20 +1,29 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +This module implements the core Latch class to be used with `httpx `_ +library. By default, the required packages to use this module are no installed. You must +install the extra requirements `httpx` to be able to use it: -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. +.. code-block:: bash -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. + $ pip install latch-sdk-telefonica[httpx] -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +.. warning:: If extra requirements are no satisfied an error will rise on module import. """ try: @@ -28,7 +37,7 @@ raise from .base import BaseLatch -from ..response import LatchResponse +from ..models import Response try: from collections.abc import Mapping @@ -37,6 +46,10 @@ class Latch(BaseLatch): + """ + Latch core class using synchronous `httpx `_ library. + """ + _session: Client def _reconfigure_session(self) -> None: @@ -54,7 +67,7 @@ def _http( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: """ HTTP Request to the specified API endpoint """ @@ -68,4 +81,4 @@ def _http( response.raise_for_status() - return LatchResponse(response.json()) + return Response(response.json()) diff --git a/src/latch_sdk/syncio/pure.py b/src/latch_sdk/syncio/pure.py index fcf783b..a0a3f92 100644 --- a/src/latch_sdk/syncio/pure.py +++ b/src/latch_sdk/syncio/pure.py @@ -1,26 +1,35 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + """ -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +This module implements the core Latch class to be used with +standard python library. It uses :class:`~http.client.HTTPConnection` and +:class:`~http.client.HTTPSConnection` aand it does not require any other +package to be installed. + +.. tip:: If your project are no using any external http package, you should use this + implementation in order to avoid to install other packages. """ +import json from urllib.parse import urlunparse from .base import BaseLatch -from ..response import LatchResponse +from ..models import Response try: from collections.abc import Mapping @@ -29,13 +38,17 @@ class Latch(BaseLatch): + """ + Latch core class using synchronous pure standard library. + """ + def _http( self, method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: """ HTTP Request to the specified API endpoint """ @@ -75,6 +88,6 @@ def _http( response_data = response.read().decode("utf8") - return LatchResponse(response_data) + return Response(json.loads(response_data)) finally: conn.close() diff --git a/src/latch_sdk/syncio/requests.py b/src/latch_sdk/syncio/requests.py index afddb1f..4b395d3 100644 --- a/src/latch_sdk/syncio/requests.py +++ b/src/latch_sdk/syncio/requests.py @@ -1,20 +1,31 @@ +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + """ -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +This module implements the core Latch class to be used with +`requests `_ library. By default, +the required packages to use this module are no installed. You must +install the extra requirements `requests` to be able to use it: + +.. code-block:: bash + + $ pip install latch-sdk-telefonica[requests] + +.. warning:: If extra requirements are no satisfied an error will rise on module import. """ try: @@ -28,7 +39,7 @@ raise from .base import BaseLatch -from ..response import LatchResponse +from ..models import Response try: from collections.abc import Mapping @@ -37,6 +48,10 @@ class Latch(BaseLatch): + """ + Latch core class using synchronous `requests `_ library. + """ + _session: Session def _reconfigure_session(self): @@ -54,7 +69,7 @@ def _http( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: """ HTTP Request to the specified API endpoint """ @@ -68,4 +83,4 @@ def _http( response.raise_for_status() - return LatchResponse(response.text) + return Response(response.json()) diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index e68925f..f0bb51c 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -1,30 +1,60 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +from functools import wraps +from typing import Any, Callable, Concatenate, ParamSpec, TypeVar def sign_data( secret: bytes, data: bytes, ) -> bytes: + """ + Signs data using a secret + """ + import hmac from base64 import b64encode from hashlib import sha1 sha1_hash = hmac.new(secret, data, sha1) return b64encode(sha1_hash.digest()) + + +T = TypeVar("T", bound=type) +TSelf = TypeVar("TSelf") +P = ParamSpec("P") + + +def wraps_and_replace_return( + meth: "Callable[Concatenate[Any, P], Any]", + return_type: T, +) -> Callable[[Callable[Concatenate[TSelf, P], T]], Callable[Concatenate[TSelf, P], T]]: + """ + Wraps a method and replace return type. + """ + + def inner(f) -> "Callable[Concatenate[TSelf, P], T]": + wrapped = wraps(meth)(f) + wrapped.__doc__ = meth.__doc__ + wrapped.__name__ = meth.__name__ + wrapped.__annotations__["return"] = return_type + + return wrapped + + return inner diff --git a/tests/asyncio/__init__.py b/tests/asyncio/__init__.py index 76d921a..a1a351c 100644 --- a/tests/asyncio/__init__.py +++ b/tests/asyncio/__init__.py @@ -1,18 +1,16 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/tests/asyncio/test_aiohttp.py b/tests/asyncio/test_aiohttp.py index 5ceb7eb..6f0bd64 100644 --- a/tests/asyncio/test_aiohttp.py +++ b/tests/asyncio/test_aiohttp.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + from typing import Any from unittest import IsolatedAsyncioTestCase diff --git a/tests/asyncio/test_base.py b/tests/asyncio/test_base.py index d50bd85..b2177b9 100644 --- a/tests/asyncio/test_base.py +++ b/tests/asyncio/test_base.py @@ -1,28 +1,27 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock from latch_sdk.asyncio.base import BaseLatch, LatchSDK from latch_sdk.exceptions import ApplicationAlreadyPaired, TokenNotFound -from latch_sdk.response import LatchResponse +from latch_sdk.models import Response from ..factory import ResponseFactory @@ -38,13 +37,11 @@ def setUp(self) -> None: return super().setUp() async def test_pair(self): - self.core.pair.return_value = LatchResponse( - ResponseFactory.pair("test_account") - ) + self.core.pair.return_value = Response(ResponseFactory.pair("test_account")) self.assertEqual(await self.latch_sdk.pair("terwrw"), "test_account") async def test_pair_error_206_token_expired(self): - self.core.pair.return_value = LatchResponse( + self.core.pair.return_value = Response( ResponseFactory.pair_error_206_token_expired() ) @@ -52,7 +49,7 @@ async def test_pair_error_206_token_expired(self): await self.latch_sdk.pair("terwrw") async def test_pair_error_205_already_paired(self): - self.core.pair.return_value = LatchResponse( + self.core.pair.return_value = Response( ResponseFactory.pair_error_205_already_paired("test_account") ) diff --git a/tests/asyncio/test_httpx.py b/tests/asyncio/test_httpx.py index 32da2ff..427f2a8 100644 --- a/tests/asyncio/test_httpx.py +++ b/tests/asyncio/test_httpx.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + from unittest import IsolatedAsyncioTestCase from unittest.mock import ANY, AsyncMock, Mock, patch diff --git a/tests/factory.py b/tests/factory.py index ec37fc6..7ac7bf2 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from typing import Literal @@ -162,3 +161,20 @@ def history(cls): ], } } + + @classmethod + def instance_list(cls): + return { + "data": { + "dKuufftAyN7Znvb73iwg": { + "name": "instance_1", + "two_factor": "DISABLED", + "lock_on_request": "DISABLED", + }, + "aabbcctAyN7Znvb73333": { + "name": "instance_2", + "two_factor": "OPT_IN", + "lock_on_request": "MANDATORY", + }, + } + } diff --git a/tests/syncio/__init__.py b/tests/syncio/__init__.py index 76d921a..a1a351c 100644 --- a/tests/syncio/__init__.py +++ b/tests/syncio/__init__.py @@ -1,18 +1,16 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/tests/syncio/test_base.py b/tests/syncio/test_base.py index c390436..9014b28 100644 --- a/tests/syncio/test_base.py +++ b/tests/syncio/test_base.py @@ -1,27 +1,26 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from unittest import TestCase from unittest.mock import Mock from latch_sdk.exceptions import ApplicationAlreadyPaired, TokenNotFound -from latch_sdk.response import LatchResponse +from latch_sdk.models import Response from latch_sdk.syncio.base import BaseLatch, LatchSDK from ..factory import ResponseFactory @@ -38,13 +37,11 @@ def setUp(self) -> None: return super().setUp() def test_pair(self): - self.core.pair.return_value = LatchResponse( - ResponseFactory.pair("test_account") - ) + self.core.pair.return_value = Response(ResponseFactory.pair("test_account")) self.assertEqual(self.latch_sdk.pair("terwrw"), "test_account") def test_pair_error_206_token_expired(self): - self.core.pair.return_value = LatchResponse( + self.core.pair.return_value = Response( ResponseFactory.pair_error_206_token_expired() ) @@ -52,7 +49,7 @@ def test_pair_error_206_token_expired(self): self.latch_sdk.pair("terwrw") def test_pair_error_205_already_paired(self): - self.core.pair.return_value = LatchResponse( + self.core.pair.return_value = Response( ResponseFactory.pair_error_205_already_paired("test_account") ) diff --git a/tests/syncio/test_httpx.py b/tests/syncio/test_httpx.py index 6b26977..7e26ef8 100644 --- a/tests/syncio/test_httpx.py +++ b/tests/syncio/test_httpx.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + from unittest import TestCase from unittest.mock import ANY, Mock, patch diff --git a/tests/syncio/test_pure.py b/tests/syncio/test_pure.py index d515fab..7b1ff14 100644 --- a/tests/syncio/test_pure.py +++ b/tests/syncio/test_pure.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" from unittest import TestCase from unittest.mock import ANY, Mock, patch @@ -35,6 +34,7 @@ def test_http_get( http_conn_mock: Mock, ): http_conn_mock.return_value = http_conn_mock + http_conn_mock.getresponse.return_value.read.return_value = b"{}" latch = Latch(self.API_ID, self.SECRET) @@ -62,6 +62,7 @@ def test_http_post( http_conn_mock: Mock, ): http_conn_mock.return_value = http_conn_mock + http_conn_mock.getresponse.return_value.read.return_value = b"{}" latch = Latch(self.API_ID, self.SECRET) @@ -93,6 +94,7 @@ def test_https_get( http_conn_mock: Mock, ): https_conn_mock.return_value = https_conn_mock + https_conn_mock.getresponse.return_value.read.return_value = b"{}" latch = Latch(self.API_ID, self.SECRET) @@ -120,6 +122,7 @@ def test_https_post( http_conn_mock: Mock, ): https_conn_mock.return_value = https_conn_mock + https_conn_mock.getresponse.return_value.read.return_value = b"{}" latch = Latch(self.API_ID, self.SECRET) @@ -151,6 +154,7 @@ def test_proxy_http_get( http_conn_mock: Mock, ): https_conn_mock.return_value = https_conn_mock + https_conn_mock.getresponse.return_value.read.return_value = b"{}" latch = Latch(self.API_ID, self.SECRET) @@ -179,6 +183,7 @@ def test_proxy_http_port_get( http_conn_mock: Mock, ): https_conn_mock.return_value = https_conn_mock + https_conn_mock.getresponse.return_value.read.return_value = b"{}" latch = Latch(self.API_ID, self.SECRET) @@ -207,6 +212,7 @@ def test_proxy_https_get( http_conn_mock: Mock, ): https_conn_mock.return_value = https_conn_mock + https_conn_mock.getresponse.return_value.read.return_value = b"{}" latch = Latch(self.API_ID, self.SECRET) diff --git a/tests/syncio/test_requests.py b/tests/syncio/test_requests.py index d554d39..43f1081 100644 --- a/tests/syncio/test_requests.py +++ b/tests/syncio/test_requests.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + from unittest import TestCase from unittest.mock import ANY, Mock, patch diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index a7686b4..c9e45f5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + from unittest import TestCase diff --git a/tests/test_sansio.py b/tests/test_sansio.py index ae13385..82cf4c4 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -1,29 +1,31 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + import secrets from datetime import datetime, timedelta, timezone +from string import Template from textwrap import dedent from typing import Callable, Mapping from unittest import TestCase from unittest.mock import Mock, patch +from parametrize import parametrize + from latch_sdk.exceptions import ( ApplicationAlreadyPaired, ApplicationNotFound, @@ -31,8 +33,14 @@ MaxActionsExceed, TokenNotFound, ) -from latch_sdk.models import ExtraFeature, HistoryResponse, Instance, Operation, Status -from latch_sdk.response import LatchResponse +from latch_sdk.models import ( + ExtraFeature, + HistoryResponse, + Instance, + Operation, + Response, + Status, +) from latch_sdk.sansio import ( LatchSansIO, check_error, @@ -52,12 +60,12 @@ class CheckErrorTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse({"data": {}}) + resp = Response({"data": {}}) check_error(resp) def test_with_error(self) -> None: - resp = LatchResponse( + resp = Response( {"error": {"code": InvalidCredentials.CODE, "message": "test message"}} ) @@ -69,16 +77,14 @@ def test_with_error(self) -> None: class ResponsePairTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse(ResponseFactory.pair("test_account")) + resp = Response(ResponseFactory.pair("test_account")) account_id = response_pair(resp) self.assertEqual(account_id, "test_account") def test_with_error(self) -> None: - resp = LatchResponse( - ResponseFactory.pair_error_206_token_expired("test message") - ) + resp = Response(ResponseFactory.pair_error_206_token_expired("test message")) with self.assertRaises(TokenNotFound) as ex: response_pair(resp) @@ -86,7 +92,7 @@ def test_with_error(self) -> None: self.assertEqual(ex.exception.message, "test message") def test_with_error_already_paired(self) -> None: - resp = LatchResponse( + resp = Response( ResponseFactory.pair_error_205_already_paired( "test_account", "test message" ) @@ -101,14 +107,12 @@ def test_with_error_already_paired(self) -> None: class ResponseNoErrorTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse(ResponseFactory.no_data()) + resp = Response(ResponseFactory.no_data()) self.assertTrue(response_no_error(resp)) def test_with_error(self) -> None: - resp = LatchResponse( - ResponseFactory.error(ApplicationNotFound.CODE, "test message") - ) + resp = Response(ResponseFactory.error(ApplicationNotFound.CODE, "test message")) with self.assertRaises(ApplicationNotFound) as ex: response_no_error(resp) @@ -118,7 +122,7 @@ def test_with_error(self) -> None: class ResponseStatusTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse(ResponseFactory.status_on("account_id")) + resp = Response(ResponseFactory.status_on("account_id")) status = response_status(resp) @@ -127,9 +131,7 @@ def test_no_error(self) -> None: self.assertTrue(status.status) def test_require_otp(self) -> None: - resp = LatchResponse( - ResponseFactory.status_on_two_factor("account_id", "123456") - ) + resp = Response(ResponseFactory.status_on_two_factor("account_id", "123456")) status = response_status(resp) @@ -140,7 +142,7 @@ def test_require_otp(self) -> None: self.assertEqual(status.two_factor.token, "123456") # type: ignore def test_with_error(self) -> None: - resp = LatchResponse( + resp = Response( {"error": {"code": MaxActionsExceed.CODE, "message": "test message"}} ) @@ -152,7 +154,7 @@ def test_with_error(self) -> None: class ResponseOperationTestCase(TestCase): def test_no_error_operation_key(self) -> None: - resp = LatchResponse( + resp = Response( {"data": {"operations": {"operation_id": {"name": "Operation Test"}}}} ) @@ -163,7 +165,7 @@ def test_no_error_operation_key(self) -> None: self.assertEqual(operation.name, "Operation Test") def test_no_error_no_key(self) -> None: - resp = LatchResponse({"data": {"operation_id": {"name": "Operation Test"}}}) + resp = Response({"data": {"operation_id": {"name": "Operation Test"}}}) operation = response_operation(resp) @@ -172,7 +174,7 @@ def test_no_error_no_key(self) -> None: self.assertEqual(operation.name, "Operation Test") def test_with_error(self) -> None: - resp = LatchResponse( + resp = Response( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -184,7 +186,7 @@ def test_with_error(self) -> None: class ResponseOperationListTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse( + resp = Response( { "data": { "operations": { @@ -211,7 +213,7 @@ def test_no_error(self) -> None: next(operation_list) def test_no_error_empty(self) -> None: - resp = LatchResponse({"data": {}}) + resp = Response({"data": {}}) operation_list = iter(response_operation_list(resp)) @@ -219,7 +221,7 @@ def test_no_error_empty(self) -> None: next(operation_list) def test_with_error(self) -> None: - resp = LatchResponse( + resp = Response( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -231,7 +233,7 @@ def test_with_error(self) -> None: class ResponseInstanceListTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse( + resp = Response( { "data": { "instance_id_1": {"name": "Instance Test 1"}, @@ -256,7 +258,7 @@ def test_no_error(self) -> None: next(instance_list) def test_no_error_empty(self) -> None: - resp = LatchResponse({"data": {}}) + resp = Response({"data": {}}) operation_list = iter(response_instance_list(resp)) @@ -264,7 +266,7 @@ def test_no_error_empty(self) -> None: next(operation_list) def test_with_error(self) -> None: - resp = LatchResponse( + resp = Response( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -276,12 +278,12 @@ def test_with_error(self) -> None: class ResponseAddInstanceTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse({"data": {"instance_id_1": "Instance Test 1"}}) + resp = Response({"data": {"instance_id_1": "Instance Test 1"}}) self.assertEqual(response_add_instance(resp), "instance_id_1") def test_with_error(self) -> None: - resp = LatchResponse( + resp = Response( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -293,7 +295,7 @@ def test_with_error(self) -> None: class ResponseHistoryTestCase(TestCase): def test_no_error(self) -> None: - resp = LatchResponse(ResponseFactory.history()) + resp = Response(ResponseFactory.history()) data = response_history(resp) @@ -544,7 +546,7 @@ def test_build_data_to_sign_no_header_no_params(self): ) -class LatchTesting(LatchSansIO[LatchResponse]): +class LatchTesting(LatchSansIO[Response]): def __init__( self, *args, @@ -555,7 +557,7 @@ def __init__( Mapping[str, str], "Mapping[str, str] | None", ], - LatchResponse, + Response, ], **kwargs, ) -> None: @@ -571,7 +573,7 @@ def _http( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: return self._http_cb(method, path, headers, params) def _reconfigure_session(self): @@ -625,7 +627,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual(path, "/api/1.0/pairWithId/eregerdscvrtrd") @@ -633,14 +635,14 @@ def _http_cb( self.assertIsNone(params) - return LatchResponse({"data": {"accountId": "latch_account_id"}}) + return Response({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.pair_with_id(account_id) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore @@ -657,7 +659,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual(path, "/api/1.0/pairWithId/eregerdscvrtrd") @@ -671,7 +673,7 @@ def _http_cb( }, ) - return LatchResponse({"data": {"accountId": "latch_account_id"}}) + return Response({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) @@ -681,7 +683,7 @@ def _http_cb( account_id, web3_account=web3_account, web3_signature=web3_signature ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore @@ -696,7 +698,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual(path, f"/api/1.0/pair/{pin}") @@ -704,14 +706,14 @@ def _http_cb( self.assertIsNone(params) - return LatchResponse({"data": {"accountId": "latch_account_id"}}) + return Response({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.pair(pin) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore @@ -726,7 +728,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual(path, f"/api/1.0/pair/{pin}") @@ -734,7 +736,7 @@ def _http_cb( self.assertIsNone(params) - return LatchResponse( + return Response( ResponseFactory.pair_error_205_already_paired("latch_account_id") ) @@ -743,7 +745,7 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.pair(pin) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore @@ -763,7 +765,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual(path, f"/api/1.0/pair/{pin}") @@ -777,7 +779,7 @@ def _http_cb( }, ) - return LatchResponse({"data": {"accountId": "latch_account_id"}}) + return Response({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) @@ -785,14 +787,161 @@ def _http_cb( resp = latch.pair(pin, web3_account=web3_account, web3_signature=web3_signature) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore http_cb.assert_called_once() - def test_status(self) -> None: + def assert_status_request( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ): + self.assertEqual(method, "GET") + + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + def assert_status_response( + self, resp: Response, *, account_id: str, status: str = "on" + ): + self.assertIsInstance(resp, Response) + + self.assertIsNotNone(resp.data) + self.assertIn(account_id, resp.data) # type: ignore + self.assertIn("status", resp.data[account_id]) # type: ignore + self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + + @parametrize( + "operation_id,instance_id,nootp,silent,expected_path", + [ + ( + None, + None, + False, + False, + "", + ), + ( + None, + None, + True, + False, + "/nootp", + ), + ( + None, + None, + True, + True, + "/nootp/silent", + ), + ( + None, + None, + False, + True, + "/silent", + ), + ( + "xxx_operation_id", + None, + False, + False, + "/op/${operation_id}", + ), + ( + "xxx_operation_id", + None, + True, + False, + "/op/${operation_id}/nootp", + ), + ( + "xxx_operation_id", + None, + True, + True, + "/op/${operation_id}/nootp/silent", + ), + ( + "xxx_operation_id", + None, + False, + True, + "/op/${operation_id}/silent", + ), + ( + None, + "yyy_instance_id", + False, + False, + "/i/${instance_id}", + ), + ( + None, + "yyy_instance_id", + True, + False, + "/i/${instance_id}/nootp", + ), + ( + None, + "yyy_instance_id", + True, + True, + "/i/${instance_id}/nootp/silent", + ), + ( + None, + "yyy_instance_id", + False, + True, + "/i/${instance_id}/silent", + ), + ( + "xxx_operation_id", + "yyy_instance_id", + False, + False, + "/op/${operation_id}/i/${instance_id}", + ), + ( + "xxx_operation_id", + "yyy_instance_id", + True, + False, + "/op/${operation_id}/i/${instance_id}/nootp", + ), + ( + "xxx_operation_id", + "yyy_instance_id", + True, + True, + "/op/${operation_id}/i/${instance_id}/nootp/silent", + ), + ( + "xxx_operation_id", + "yyy_instance_id", + False, + True, + "/op/${operation_id}/i/${instance_id}/silent", + ), + ], + ) # type: ignore + def test_status( + self, + operation_id: str | None, + instance_id: str | None, + nootp: bool, + silent: bool, + expected_path: str, + ) -> None: account_id = "eregerdscvrtrd" def _http_cb( @@ -800,31 +949,39 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual(path, f"/api/1.0/status/{account_id}") - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) + ) -> Response: + self.assert_status_request(method, path, headers, params) + self.assertEqual( + path, + Template( + "/api/1.0/status/${account_id}" + expected_path + ).safe_substitute( + { + "account_id": account_id, + "operation_id": operation_id, + "instance_id": instance_id, + } + ), + ) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({"data": {account_id: {"status": "on"}}}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.status(account_id) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.status( + account_id, + operation_id=operation_id, + instance_id=instance_id, + nootp=nootp, + silent=silent, + ) + self.assert_status_response(resp, account_id=account_id, status="on") http_cb.assert_called_once() - def test_status_nootp(self) -> None: + def test_unpair(self) -> None: account_id = "eregerdscvrtrd" def _http_cb( @@ -832,31 +989,53 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") - self.assertEqual(path, f"/api/1.0/status/{account_id}/nootp") + self.assertEqual(path, f"/api/1.0/unpair/{account_id}") self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.status(account_id, nootp=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.unpair(account_id) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_status_silent(self) -> None: + @parametrize( + "operation_id,instance_id,expected_path", + [ + ( + None, + None, + "", + ), + ( + "xxx_operation_id", + None, + "/op/${operation_id}", + ), + ( + None, + "yyy_instance_id", + "/i/${instance_id}", + ), + ( + "xxx_operation_id", + "yyy_instance_id", + "/op/${operation_id}/i/${instance_id}", + ), + ], + ) # type: ignore + def test_lock( + self, operation_id: str | None, instance_id: str | None, expected_path: str + ) -> None: account_id = "eregerdscvrtrd" def _http_cb( @@ -864,31 +1043,64 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") + ) -> Response: + self.assertEqual(method, "POST") - self.assertEqual(path, f"/api/1.0/status/{account_id}/silent") + self.assertEqual( + path, + Template("/api/1.0/lock/${account_id}" + expected_path).safe_substitute( + { + "account_id": account_id, + "operation_id": operation_id, + "instance_id": instance_id, + } + ), + ) self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.status(account_id, silent=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.lock( + account_id, operation_id=operation_id, instance_id=instance_id + ) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_status_nootp_and_silent(self) -> None: + @parametrize( + "operation_id,instance_id,expected_path", + [ + ( + None, + None, + "", + ), + ( + "xxx_operation_id", + None, + "/op/${operation_id}", + ), + ( + None, + "yyy_instance_id", + "/i/${instance_id}", + ), + ( + "xxx_operation_id", + "yyy_instance_id", + "/op/${operation_id}/i/${instance_id}", + ), + ], + ) # type: ignore + def test_unlock( + self, operation_id: str | None, instance_id: str | None, expected_path: str + ) -> None: account_id = "eregerdscvrtrd" def _http_cb( @@ -896,169 +1108,178 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") + ) -> Response: + self.assertEqual(method, "POST") - self.assertEqual(path, f"/api/1.0/status/{account_id}/nootp/silent") + self.assertEqual( + path, + Template( + "/api/1.0/unlock/${account_id}" + expected_path + ).safe_substitute( + { + "account_id": account_id, + "operation_id": operation_id, + "instance_id": instance_id, + } + ), + ) self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.status(account_id, nootp=True, silent=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.unlock( + account_id, operation_id=operation_id, instance_id=instance_id + ) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_operation_status(self) -> None: + def test_unlock_operation(self) -> None: account_id = "eregerdscvrtrd" - operation_id = "ggggrer4tgfvbd6t5y4t" + operation_id = "terthbdvcs4g5hxt" def _http_cb( method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") + ) -> Response: + self.assertEqual(method, "POST") - self.assertEqual(path, f"/api/1.0/status/{account_id}/op/{operation_id}") + self.assertEqual(path, f"/api/1.0/unlock/{account_id}/op/{operation_id}") self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.operation_status(account_id, operation_id) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.unlock(account_id, operation_id=operation_id) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_operation_status_nootp(self) -> None: + def test_history(self) -> None: account_id = "eregerdscvrtrd" - operation_id = "ggggrer4tgfvbd6t5y4t" + to_dt = datetime.now(tz=timezone.utc) + from_dt = to_dt - timedelta(days=2) def _http_cb( method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual( - path, f"/api/1.0/status/{account_id}/op/{operation_id}/nootp" + path, + f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}/{round(to_dt.timestamp() * 1000)}", ) self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.operation_status(account_id, operation_id, nootp=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.history(account_id, from_dt=from_dt, to_dt=to_dt) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_operation_status_silent(self) -> None: + def test_history_no_from(self) -> None: account_id = "eregerdscvrtrd" - operation_id = "ggggrer4tgfvbd6t5y4t" + to_dt = datetime.now(tz=timezone.utc) def _http_cb( method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual( - path, f"/api/1.0/status/{account_id}/op/{operation_id}/silent" + path, + f"/api/1.0/history/{account_id}/0/{round(to_dt.timestamp() * 1000)}", ) self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.operation_status(account_id, operation_id, silent=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.history(account_id, to_dt=to_dt) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_operation_status_nootp_and_silent(self) -> None: + @patch("latch_sdk.sansio.datetime") + def test_history_no_to(self, datetime_cls_mock: Mock) -> None: + current_dt: datetime = datetime.now(tz=timezone.utc) + + datetime_cls_mock.now.return_value = current_dt + datetime_cls_mock.fromtimestamp.side_effect = ( + lambda *args, **kwargs: datetime.fromtimestamp(*args, **kwargs) + ) + account_id = "eregerdscvrtrd" - operation_id = "ggggrer4tgfvbd6t5y4t" + from_dt = datetime.now(tz=timezone.utc) - timedelta(days=2) def _http_cb( method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual( - path, f"/api/1.0/status/{account_id}/op/{operation_id}/nootp/silent" + path, + f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}" + f"/{round(round(current_dt.timestamp() * 1000))}", ) self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({"data": {account_id: {"status": "on"}}}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.operation_status(account_id, operation_id, nootp=True, silent=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + resp = latch.history(account_id, from_dt=from_dt) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_unpair(self) -> None: + @patch("latch_sdk.sansio.datetime") + def test_history_no_from_no_to(self, datetime_cls_mock: Mock) -> None: + current_dt: datetime = datetime.now(tz=timezone.utc) + + datetime_cls_mock.now.return_value = current_dt + datetime_cls_mock.fromtimestamp.side_effect = ( + lambda *args, **kwargs: datetime.fromtimestamp(*args, **kwargs) + ) + account_id = "eregerdscvrtrd" def _http_cb( @@ -1066,385 +1287,149 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") - self.assertEqual(path, f"/api/1.0/unpair/{account_id}") + self.assertEqual( + path, + f"/api/1.0/history/{account_id}/0/{round(current_dt.timestamp() * 1000)}", + ) self.assert_request(method, path, headers, params) self.assertIsNone(params) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.unpair(account_id) - self.assertIsInstance(resp, LatchResponse) + resp = latch.history(account_id) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_lock(self) -> None: - account_id = "eregerdscvrtrd" + def test_create_operation(self) -> None: + parent_id = "eregerdscvrtrd" def _http_cb( method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "POST") + ) -> Response: + self.assertEqual(method, "PUT") - self.assertEqual(path, f"/api/1.0/lock/{account_id}") + self.assertEqual( + path, + "/api/1.0/operation", + ) self.assert_request(method, path, headers, params) - self.assertIsNone(params) + self.assertEqual( + { + "parentId": parent_id, + "name": "new_op", + "two_factor": "DISABLED", + "lock_on_request": "DISABLED", + }, + params, + ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.lock(account_id) - self.assertIsInstance(resp, LatchResponse) + resp = latch.operation_create(parent_id, "new_op") + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_lock_operation(self) -> None: - account_id = "eregerdscvrtrd" - operation_id = "terthbdvcs4g5hxt" + def test_opertion_update(self) -> None: + operation_id = "eregerdscvrtrd" def _http_cb( method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") - self.assertEqual(path, f"/api/1.0/lock/{account_id}/op/{operation_id}") + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) self.assert_request(method, path, headers, params) - self.assertIsNone(params) + self.assertEqual( + { + "name": "new_op", + }, + params, + ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.lock(account_id, operation_id=operation_id) - self.assertIsInstance(resp, LatchResponse) + resp = latch.operation_update(operation_id, name="new_op") + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_unlock(self) -> None: - account_id = "eregerdscvrtrd" + def test_opertion_update_fail(self) -> None: + operation_id = "eregerdscvrtrd" + + http_cb = Mock() + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + with self.assertRaises(ValueError): + latch.operation_update(operation_id) + + http_cb.assert_not_called() + + def test_opertion_update_two_factor(self) -> None: + operation_id = "eregerdscvrtrd" def _http_cb( method: str, path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") - self.assertEqual(path, f"/api/1.0/unlock/{account_id}") + self.assertEqual( + path, + f"/api/1.0/operation/{operation_id}", + ) self.assert_request(method, path, headers, params) - self.assertIsNone(params) + self.assertEqual( + {"name": "new_op", "two_factor": ExtraFeature.MANDATORY.value}, + params, + ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.unlock(account_id) - self.assertIsInstance(resp, LatchResponse) + resp = latch.operation_update( + operation_id, name="new_op", two_factor=ExtraFeature.MANDATORY + ) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_unlock_operation(self) -> None: - account_id = "eregerdscvrtrd" - operation_id = "terthbdvcs4g5hxt" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "POST") - - self.assertEqual(path, f"/api/1.0/unlock/{account_id}/op/{operation_id}") - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.unlock(account_id, operation_id=operation_id) - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - def test_history(self) -> None: - account_id = "eregerdscvrtrd" - to_dt = datetime.now(tz=timezone.utc) - from_dt = to_dt - timedelta(days=2) - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, - f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}/{round(to_dt.timestamp() * 1000)}", - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.history(account_id, from_dt=from_dt, to_dt=to_dt) - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - def test_history_no_from(self) -> None: - account_id = "eregerdscvrtrd" - to_dt = datetime.now(tz=timezone.utc) - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, - f"/api/1.0/history/{account_id}/0/{round(to_dt.timestamp() * 1000)}", - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.history(account_id, to_dt=to_dt) - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - @patch("latch_sdk.sansio.datetime") - def test_history_no_to(self, datetime_cls_mock: Mock) -> None: - current_dt: datetime = datetime.now(tz=timezone.utc) - - datetime_cls_mock.now.return_value = current_dt - datetime_cls_mock.fromtimestamp.side_effect = ( - lambda *args, **kwargs: datetime.fromtimestamp(*args, **kwargs) - ) - - account_id = "eregerdscvrtrd" - from_dt = datetime.now(tz=timezone.utc) - timedelta(days=2) - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, - f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}" - f"/{round(round(current_dt.timestamp() * 1000))}", - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.history(account_id, from_dt=from_dt) - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - @patch("latch_sdk.sansio.datetime") - def test_history_no_from_no_to(self, datetime_cls_mock: Mock) -> None: - current_dt: datetime = datetime.now(tz=timezone.utc) - - datetime_cls_mock.now.return_value = current_dt - datetime_cls_mock.fromtimestamp.side_effect = ( - lambda *args, **kwargs: datetime.fromtimestamp(*args, **kwargs) - ) - - account_id = "eregerdscvrtrd" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, - f"/api/1.0/history/{account_id}/0/{round(current_dt.timestamp() * 1000)}", - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.history(account_id) - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - def test_create_operation(self) -> None: - parent_id = "eregerdscvrtrd" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "PUT") - - self.assertEqual( - path, - "/api/1.0/operation", - ) - self.assert_request(method, path, headers, params) - - self.assertEqual( - { - "parentId": parent_id, - "name": "new_op", - "two_factor": "DISABLED", - "lock_on_request": "DISABLED", - }, - params, - ) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.create_operation(parent_id, "new_op") - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - def test_update_operation(self) -> None: - operation_id = "eregerdscvrtrd" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "POST") - - self.assertEqual( - path, - f"/api/1.0/operation/{operation_id}", - ) - self.assert_request(method, path, headers, params) - - self.assertEqual( - { - "name": "new_op", - }, - params, - ) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.update_operation(operation_id, "new_op") - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - def test_update_operation_two_factor(self) -> None: - operation_id = "eregerdscvrtrd" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "POST") - - self.assertEqual( - path, - f"/api/1.0/operation/{operation_id}", - ) - self.assert_request(method, path, headers, params) - - self.assertEqual( - {"name": "new_op", "two_factor": ExtraFeature.MANDATORY.value}, - params, - ) - - return LatchResponse({}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.update_operation( - operation_id, "new_op", two_factor=ExtraFeature.MANDATORY - ) - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - def test_update_operation_lock_on_request(self) -> None: + def test_opertion_update_lock_on_request(self) -> None: operation_id = "eregerdscvrtrd" def _http_cb( @@ -1452,7 +1437,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual( @@ -1466,20 +1451,20 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.update_operation( - operation_id, "new_op", lock_on_request=ExtraFeature.OPT_IN + resp = latch.operation_update( + operation_id, name="new_op", lock_on_request=ExtraFeature.OPT_IN ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_update_operation_two_factor_and_lock_on_request(self) -> None: + def test_opertion_update_two_factor_and_lock_on_request(self) -> None: operation_id = "eregerdscvrtrd" def _http_cb( @@ -1487,7 +1472,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual( @@ -1505,19 +1490,19 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.update_operation( + resp = latch.operation_update( operation_id, - "new_op", + name="new_op", two_factor=ExtraFeature.MANDATORY, lock_on_request=ExtraFeature.OPT_IN, ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1529,7 +1514,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "DELETE") self.assertEqual( @@ -1542,16 +1527,16 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.delete_operation( + resp = latch.operation_remove( operation_id, ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1561,7 +1546,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual( @@ -1574,14 +1559,14 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.get_operations() - self.assertIsInstance(resp, LatchResponse) + resp = latch.operation_list() + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1593,7 +1578,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual( @@ -1606,16 +1591,16 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.get_operations( - operation_id=operation_id, + resp = latch.operation_list( + parent_id=operation_id, ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1627,7 +1612,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual( @@ -1640,14 +1625,14 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.get_instances(account_id) - self.assertIsInstance(resp, LatchResponse) + resp = latch.instance_list(account_id) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1660,7 +1645,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "GET") self.assertEqual( @@ -1673,17 +1658,17 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.get_instances( + resp = latch.instance_list( account_id, operation_id=operation_id, ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1696,7 +1681,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "PUT") self.assertEqual( @@ -1707,18 +1692,18 @@ def _http_cb( self.assertEqual(params, {"instances": "new_instance"}) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.create_instance( + resp = latch.instance_create( "new_instance", account_id, operation_id=operation_id, ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1730,7 +1715,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "PUT") self.assertEqual( @@ -1741,308 +1726,17 @@ def _http_cb( self.assertEqual(params, {"instances": "new_instance"}) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.create_instance( + resp = latch.instance_create( "new_instance", account_id, ) - self.assertIsInstance(resp, LatchResponse) - - http_cb.assert_called_once() - - def test_instance_status(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual(path, f"/api/1.0/status/{account_id}/i/{instance_id}") - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status(instance_id, account_id) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore - - http_cb.assert_called_once() - - def test_instance_status_nootp(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, f"/api/1.0/status/{account_id}/i/{instance_id}/nootp" - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status(instance_id, account_id, nootp=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore - - http_cb.assert_called_once() - - def test_instance_status_silent(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, f"/api/1.0/status/{account_id}/i/{instance_id}/silent" - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status(instance_id, account_id, silent=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore - - http_cb.assert_called_once() - - def test_instance_status_nootp_and_silent(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, f"/api/1.0/status/{account_id}/i/{instance_id}/nootp/silent" - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status(instance_id, account_id, nootp=True, silent=True) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore - - http_cb.assert_called_once() - - def test_instance_operation_status(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - operation_id = "t43yhtrbvecw4v5e" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}" - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status(instance_id, account_id, operation_id=operation_id) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore - - http_cb.assert_called_once() - - def test_instance_operation_status_nootp(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - operation_id = "t43yhtrbvecw4v5e" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, - f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}/nootp", - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status( - instance_id, account_id, operation_id=operation_id, nootp=True - ) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore - - http_cb.assert_called_once() - - def test_instance_operation_status_silent(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - operation_id = "t43yhtrbvecw4v5e" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, - f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}/silent", - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status( - instance_id, account_id, operation_id=operation_id, silent=True - ) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore - - http_cb.assert_called_once() - - def test_instance_operation_status_nootp_and_silent(self) -> None: - account_id = "eregerdscvrtrd" - instance_id = "435465grebhy5t4" - operation_id = "t43yhtrbvecw4v5e" - - def _http_cb( - method: str, - path: str, - headers: Mapping[str, str], - params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: - self.assertEqual(method, "GET") - - self.assertEqual( - path, - f"/api/1.0/status/{account_id}/op/{operation_id}/i/{instance_id}/nootp/silent", - ) - self.assert_request(method, path, headers, params) - - self.assertIsNone(params) - - return LatchResponse({"data": {account_id: {"status": "on"}}}) - - http_cb = Mock(side_effect=_http_cb) - - latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - - resp = latch.instance_status( - instance_id, account_id, operation_id=operation_id, nootp=True, silent=True - ) - self.assertIsInstance(resp, LatchResponse) - - self.assertIsNotNone(resp.data) - self.assertIn(account_id, resp.data) # type: ignore - self.assertIn("status", resp.data[account_id]) # type: ignore - self.assertEqual(resp.data[account_id]["status"], "on") # type: ignore + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2055,7 +1749,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual( @@ -2071,14 +1765,14 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.update_instance(instance_id, account_id, name="new_op") - self.assertIsInstance(resp, LatchResponse) + resp = latch.instance_update(instance_id, account_id, name="new_op") + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2091,7 +1785,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual( @@ -2105,16 +1799,16 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.update_instance( + resp = latch.instance_update( instance_id, account_id, name="new_op", two_factor=ExtraFeature.MANDATORY ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2127,7 +1821,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual( @@ -2141,16 +1835,16 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.update_instance( + resp = latch.instance_update( instance_id, account_id, name="new_op", lock_on_request=ExtraFeature.OPT_IN ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2163,7 +1857,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual( @@ -2181,20 +1875,20 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.update_instance( + resp = latch.instance_update( instance_id, account_id, name="new_op", two_factor=ExtraFeature.MANDATORY, lock_on_request=ExtraFeature.OPT_IN, ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2208,7 +1902,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "POST") self.assertEqual( @@ -2224,16 +1918,16 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.update_instance( + resp = latch.instance_update( instance_id, account_id, operation_id=operation_id, name="new_op" ) - self.assertIsInstance(resp, LatchResponse) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2254,7 +1948,7 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) with self.assertRaises(ValueError): - latch.update_instance(instance_id, account_id) + latch.instance_update(instance_id, account_id) http_cb.assert_not_called() @@ -2267,7 +1961,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "DELETE") self.assertEqual( @@ -2280,14 +1974,14 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.delete_instance(instance_id, account_id) - self.assertIsInstance(resp, LatchResponse) + resp = latch.instance_remove(instance_id, account_id) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2301,7 +1995,7 @@ def _http_cb( path: str, headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, - ) -> LatchResponse: + ) -> Response: self.assertEqual(method, "DELETE") self.assertEqual( @@ -2314,14 +2008,14 @@ def _http_cb( params, ) - return LatchResponse({}) + return Response({}) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.delete_instance(instance_id, account_id, operation_id=operation_id) - self.assertIsInstance(resp, LatchResponse) + resp = latch.instance_remove(instance_id, account_id, operation_id=operation_id) + self.assertIsInstance(resp, Response) http_cb.assert_called_once() diff --git a/tests/test_utils.py b/tests/test_utils.py index daa4e3a..8be3de9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,21 +1,20 @@ -""" -This library offers an API to use LatchAuth in a python environment. -Copyright (C) 2013 Telefonica Digital España S.L. - -This library is free software you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -""" +# This library offers an API to use LatchAuth in a python environment. +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + import secrets from unittest import TestCase From 67ea36a7d0afec6155f5d6f54975ab406b7cc456 Mon Sep 17 00:00:00 2001 From: Alfred Date: Fri, 28 Feb 2025 11:54:11 +0100 Subject: [PATCH 11/25] Added TOTP and Application operations --- .gitignore | 5 +- src/latch_sdk/__init__.py | 1 - src/latch_sdk/asyncio/__init__.py | 1 - src/latch_sdk/asyncio/aiohttp.py | 3 +- src/latch_sdk/asyncio/base.py | 46 ++- src/latch_sdk/asyncio/httpx.py | 3 +- src/latch_sdk/cli/__init__.py | 8 +- src/latch_sdk/cli/account.py | 205 +++++++++ src/latch_sdk/cli/application.py | 242 +++++------ src/latch_sdk/cli/instance.py | 7 +- src/latch_sdk/cli/operation.py | 1 - src/latch_sdk/cli/renders.py | 66 ++- src/latch_sdk/cli/totp.py | 101 +++++ src/latch_sdk/cli/utils.py | 1 - src/latch_sdk/exceptions.py | 1 - src/latch_sdk/models.py | 160 ++++++- src/latch_sdk/sansio.py | 338 ++++++++++++--- src/latch_sdk/syncio/__init__.py | 1 - src/latch_sdk/syncio/base.py | 46 ++- src/latch_sdk/syncio/httpx.py | 3 +- src/latch_sdk/syncio/pure.py | 3 +- src/latch_sdk/syncio/requests.py | 3 +- src/latch_sdk/utils.py | 1 - tests/asyncio/__init__.py | 1 - tests/asyncio/test_aiohttp.py | 37 +- tests/asyncio/test_base.py | 116 +++++- tests/asyncio/test_httpx.py | 37 +- tests/factory.py | 97 ++++- tests/syncio/__init__.py | 1 - tests/syncio/test_base.py | 114 ++++- tests/syncio/test_httpx.py | 25 +- tests/syncio/test_pure.py | 29 +- tests/syncio/test_requests.py | 25 +- tests/test_exceptions.py | 1 - tests/test_sansio.py | 667 +++++++++++++++++++++++------- tests/test_utils.py | 1 - 36 files changed, 1873 insertions(+), 524 deletions(-) create mode 100644 src/latch_sdk/cli/account.py create mode 100644 src/latch_sdk/cli/totp.py diff --git a/.gitignore b/.gitignore index 63544fd..454c72f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ dist coverage.xml # VSCode -.vscode \ No newline at end of file +.vscode + +# Sphinx +docs/build \ No newline at end of file diff --git a/src/latch_sdk/__init__.py b/src/latch_sdk/__init__.py index a1a351c..cb1b655 100644 --- a/src/latch_sdk/__init__.py +++ b/src/latch_sdk/__init__.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/src/latch_sdk/asyncio/__init__.py b/src/latch_sdk/asyncio/__init__.py index fb5452f..884579d 100644 --- a/src/latch_sdk/asyncio/__init__.py +++ b/src/latch_sdk/asyncio/__init__.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/src/latch_sdk/asyncio/aiohttp.py b/src/latch_sdk/asyncio/aiohttp.py index 0add15c..51c44f2 100644 --- a/src/latch_sdk/asyncio/aiohttp.py +++ b/src/latch_sdk/asyncio/aiohttp.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -76,4 +75,4 @@ async def _http( method, self.build_url(path), data=params, headers=headers ) as response: response.raise_for_status() - return Response(await response.json()) + return Response(await response.json() if await response.text() else {}) diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index d0a56f5..91bfc3f 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -25,14 +24,17 @@ P, TFactory, TReturnType, - response_add_instance, - response_history, + response_account_history, + response_account_pair, + response_account_status, + response_application_create, + response_application_list, + response_instance_create, response_instance_list, response_no_error, response_operation, response_operation_list, - response_pair, - response_status, + response_totp, ) from ..utils import wraps_and_replace_return @@ -64,20 +66,20 @@ class LatchSDK(LatchSDKSansIO[Awaitable[Response]]): Latch SDK asynchronous main class. """ - pair = wrap_method(response_pair, BaseLatch.pair) - pair_with_id = wrap_method( - response_pair, - BaseLatch.pair_with_id, + account_pair = wrap_method(response_account_pair, BaseLatch.account_pair) + account_pair_with_id = wrap_method( + response_account_pair, + BaseLatch.account_pair_with_id, ) - unpair = wrap_method(response_no_error, BaseLatch.unpair) + account_unpair = wrap_method(response_no_error, BaseLatch.account_unpair) - status = wrap_method(response_status, BaseLatch.status) + account_status = wrap_method(response_account_status, BaseLatch.account_status) - lock = wrap_method(response_no_error, BaseLatch.lock) - unlock = wrap_method(response_no_error, BaseLatch.unlock) + account_lock = wrap_method(response_no_error, BaseLatch.account_lock) + account_unlock = wrap_method(response_no_error, BaseLatch.account_unlock) - history = wrap_method(response_history, BaseLatch.history) + account_history = wrap_method(response_account_history, BaseLatch.account_history) operation_list = wrap_method(response_operation_list, BaseLatch.operation_list) operation_create = wrap_method(response_operation, BaseLatch.operation_create) @@ -85,6 +87,20 @@ class LatchSDK(LatchSDKSansIO[Awaitable[Response]]): operation_remove = wrap_method(response_no_error, BaseLatch.operation_remove) instance_list = wrap_method(response_instance_list, BaseLatch.instance_list) - instance_create = wrap_method(response_add_instance, BaseLatch.instance_create) + instance_create = wrap_method(response_instance_create, BaseLatch.instance_create) instance_update = wrap_method(response_no_error, BaseLatch.instance_update) instance_remove = wrap_method(response_no_error, BaseLatch.instance_remove) + + totp_load = wrap_method(response_totp, BaseLatch.totp_load) + totp_create = wrap_method(response_totp, BaseLatch.totp_create) + totp_validate = wrap_method(response_no_error, BaseLatch.totp_validate) + totp_remove = wrap_method(response_no_error, BaseLatch.totp_remove) + + application_list = wrap_method( + response_application_list, BaseLatch.application_list + ) + application_create = wrap_method( + response_application_create, BaseLatch.application_create + ) + application_update = wrap_method(response_no_error, BaseLatch.application_update) + application_remove = wrap_method(response_no_error, BaseLatch.application_remove) diff --git a/src/latch_sdk/asyncio/httpx.py b/src/latch_sdk/asyncio/httpx.py index 3cda389..c193782 100644 --- a/src/latch_sdk/asyncio/httpx.py +++ b/src/latch_sdk/asyncio/httpx.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -81,4 +80,4 @@ async def _http( response.raise_for_status() - return Response(response.json()) + return Response(response.json() if response.text else {}) diff --git a/src/latch_sdk/cli/__init__.py b/src/latch_sdk/cli/__init__.py index ece3dc1..3e4673e 100644 --- a/src/latch_sdk/cli/__init__.py +++ b/src/latch_sdk/cli/__init__.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -18,7 +17,10 @@ import click +from .account import account from .application import application +from .operation import operation +from .totp import totp from ..syncio import LatchSDK from ..syncio.pure import Latch @@ -52,8 +54,10 @@ def cli(ctx: click.Context, app_id: str, secret: str): ctx.obj = LatchSDK(Latch(app_id, secret)) +cli.add_command(account) +cli.add_command(operation) +cli.add_command(totp) cli.add_command(application) - if __name__ == "__main__": cli() diff --git a/src/latch_sdk/cli/account.py b/src/latch_sdk/cli/account.py new file mode 100644 index 0000000..e290f0b --- /dev/null +++ b/src/latch_sdk/cli/account.py @@ -0,0 +1,205 @@ +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +from datetime import datetime + +import click + +from .instance import instance +from .renders import ( + render_account, + render_history_response, + render_status, +) +from .utils import pass_latch_sdk +from ..syncio import LatchSDK + + +@click.group +def account(): + """Account actions""" + + +account.add_command(instance) + + +@account.command() +@click.argument("TOKEN", type=str, required=True) +@click.option( + "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" +) +@click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") +@pass_latch_sdk +def pair( + latch: LatchSDK, + token: str, + web3_account: str | None = None, + web3_signature: str | None = None, +): + """ + Pair a new latch. + """ + result = latch.account_pair( + token, web3_account=web3_account, web3_signature=web3_signature + ) + + render_account(result) + + +@account.command(name="pair-with-id") +@click.argument("USER_ID", type=str, required=True) +@click.option( + "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" +) +@click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") +@pass_latch_sdk +def pair_with_id( + latch: LatchSDK, + account_id: str, + web3_account: str | None = None, + web3_signature: str | None = None, +): + """ + Pair with user id a new latch. + """ + result = latch.account_pair_with_id( + account_id, web3_account=web3_account, web3_signature=web3_signature + ) + + render_account(result) + + +@account.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@pass_latch_sdk +def unpair( + latch: LatchSDK, + account_id: str, +): + """ + Unpair a latch + """ + latch.account_unpair(account_id) + + click.echo("Account unpaired") + + +@account.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--operation-id", "-o", type=str, required=False, help="Operation identifier" +) +@click.option( + "--instance-id", "-i", type=str, required=False, help="Instances identifier" +) +@click.option("--nootp", default=False, is_flag=True, required=False, help="Avoid OTP") +@click.option( + "--silent", + default=False, + is_flag=True, + required=False, + help="Do not push notification", +) +@pass_latch_sdk +def status( + latch: LatchSDK, + account_id: str, + operation_id: "str | None", + instance_id: "str | None", + nootp: bool, + silent: bool, +): + """ + Get latch status + """ + status = latch.account_status( + account_id, + operation_id=operation_id, + instance_id=instance_id, + nootp=nootp, + silent=silent, + ) + + render_status(status) + + +@account.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--operation-id", "-o", type=str, required=False, help="Operation identifier" +) +@click.option( + "--instance-id", "-i", type=str, required=False, help="Instances identifier" +) +@pass_latch_sdk +def lock( + latch: LatchSDK, + account_id: str, + operation_id: "str | None", + instance_id: "str | None", +): + """Lock a latch""" + latch.account_lock( + account_id, + operation_id=operation_id, + instance_id=instance_id, + ) + + click.secho("Latch locked", bg="red") + + +@account.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--operation-id", "-o", type=str, required=False, help="Operation identifier" +) +@click.option( + "--instance-id", "-i", type=str, required=False, help="Instances identifier" +) +@pass_latch_sdk +def unlock( + latch: LatchSDK, + account_id: str, + operation_id: "str | None", + instance_id: "str | None", +): + """Unlock a latch""" + latch.account_unlock( + account_id, + operation_id=operation_id, + instance_id=instance_id, + ) + + click.secho("Latch unlocked", bg="green") + + +@account.command() +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option( + "--from", "from_dt", type=click.DateTime(), required=False, help="Date time from" +) +@click.option( + "--to", "to_dt", type=click.DateTime(), required=False, help="Date time to" +) +@pass_latch_sdk +def history( + latch: LatchSDK, account_id: str, from_dt: datetime | None, to_dt: datetime | None +): + """Show latch actions history""" + history = latch.account_history(account_id, from_dt=from_dt, to_dt=to_dt) + + render_history_response(history) diff --git a/src/latch_sdk/cli/application.py b/src/latch_sdk/cli/application.py index bf7159c..dd1f512 100644 --- a/src/latch_sdk/cli/application.py +++ b/src/latch_sdk/cli/application.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -16,191 +15,148 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -from datetime import datetime - import click -from .instance import instance -from .operation import operation -from .renders import ( - render_account, - render_history_response, - render_status, -) +from .renders import render_application_create_response, render_applications +from .types import EnumChoice from .utils import pass_latch_sdk +from ..models import ExtraFeature from ..syncio import LatchSDK -@click.group +@click.group() def application(): """Application actions""" -application.add_command(operation) -application.add_command(instance) - - -@application.command() -@click.argument("TOKEN", type=str, required=True) +@application.command(name="create") +@click.option("--name", "-n", type=str, required=True, help="Operation name") @click.option( - "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" + "--two-factor", + "-t", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + default=ExtraFeature.DISABLED, + help="Two factor feature", ) -@click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") -@pass_latch_sdk -def pair( - latch: LatchSDK, - token: str, - web3_account: str | None = None, - web3_signature: str | None = None, -): - """ - Pair a new latch. - """ - result = latch.pair(token, web3_account=web3_account, web3_signature=web3_signature) - - render_account(result) - - -@application.command(name="pair-with-id") -@click.argument("USER_ID", type=str, required=True) @click.option( - "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" -) -@click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") -@pass_latch_sdk -def pair_with_id( - latch: LatchSDK, - account_id: str, - web3_account: str | None = None, - web3_signature: str | None = None, -): - """ - Pair with user id a new latch. - """ - result = latch.pair_with_id( - account_id, web3_account=web3_account, web3_signature=web3_signature - ) - - render_account(result) - - -@application.command() -@click.argument("ACCOUNT_ID", type=str, required=True) -@pass_latch_sdk -def unpair( - latch: LatchSDK, - account_id: str, -): - """ - Unpair a latch - """ - latch.unpair(account_id) - - click.echo("Account unpaired") - - -@application.command() -@click.argument("ACCOUNT_ID", type=str, required=True) -@click.option( - "--operation-id", "-o", type=str, required=False, help="Operation identifier" + "--lock-on-request", + "-l", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + default=ExtraFeature.DISABLED, + help="Lock on request feature", ) @click.option( - "--instance-id", "-i", type=str, required=False, help="Instances identifier" + "--contact-mail", + "-m", + type=str, + required=False, + help="Conctact email", ) -@click.option("--nootp", default=False, is_flag=True, required=False, help="Avoid OTP") @click.option( - "--silent", - default=False, - is_flag=True, + "--contact-phone", + "-p", + type=str, required=False, - help="Do not push notification", + help="Conctact phone number", ) @pass_latch_sdk -def status( +def create( latch: LatchSDK, - account_id: str, - operation_id: "str | None", - instance_id: "str | None", - nootp: bool, - silent: bool, + name: str, + two_factor: ExtraFeature, + lock_on_request: ExtraFeature, + contact_mail: "str | None", + contact_phone: "str | None", ): - """ - Get latch status - """ - status = latch.status( - account_id, - operation_id=operation_id, - instance_id=instance_id, - nootp=nootp, - silent=silent, + """Create a new application""" + application = latch.application_create( + name, + two_factor=two_factor, + lock_on_request=lock_on_request, + contact_mail=contact_mail or "", + contact_phone=contact_phone or "", ) - render_status(status) + render_application_create_response(application) -@application.command() -@click.argument("ACCOUNT_ID", type=str, required=True) +@application.command(name="update") +@click.argument("APPLICATION_ID", type=str, required=True) +@click.option("--name", "-n", type=str, required=False, help="Operation name") +@click.option( + "--two-factor", + "-t", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + help="Two factor feature", +) +@click.option( + "--lock-on-request", + "-l", + type=EnumChoice(ExtraFeature, case_sensitive=False), + required=False, + help="Lock on request feature", +) @click.option( - "--operation-id", "-o", type=str, required=False, help="Operation identifier" + "--contact-mail", + "-m", + type=str, + required=False, + help="Conctact email", ) @click.option( - "--instance-id", "-i", type=str, required=False, help="Instances identifier" + "--contact-phone", + "-p", + type=str, + required=False, + help="Conctact phone number", ) @pass_latch_sdk -def lock( +def update( latch: LatchSDK, - account_id: str, - operation_id: "str | None", - instance_id: "str | None", + application_id: str, + name: "str | None", + two_factor: "ExtraFeature | None", + lock_on_request: "ExtraFeature | None", + contact_mail: "str | None", + contact_phone: "str | None", ): - """Lock a latch""" - latch.lock( - account_id, - operation_id=operation_id, - instance_id=instance_id, + """Update a given application""" + latch.application_update( + application_id, + name=name, + two_factor=two_factor, + lock_on_request=lock_on_request, + contact_mail=contact_mail, + contact_phone=contact_phone, ) - click.secho("Latch locked", bg="red") + click.secho("Application updated", bg="green") -@application.command() -@click.argument("ACCOUNT_ID", type=str, required=True) -@click.option( - "--operation-id", "-o", type=str, required=False, help="Operation identifier" -) -@click.option( - "--instance-id", "-i", type=str, required=False, help="Instances identifier" -) +@application.command(name="list") @pass_latch_sdk -def unlock( +def lst( latch: LatchSDK, - account_id: str, - operation_id: "str | None", - instance_id: "str | None", ): - """Unlock a latch""" - latch.unlock( - account_id, - operation_id=operation_id, - instance_id=instance_id, - ) + """List registered applications""" + applications = latch.application_list() - click.secho("Latch unlocked", bg="green") + render_applications(applications) -@application.command() -@click.argument("ACCOUNT_ID", type=str, required=True) -@click.option( - "--from", "from_dt", type=click.DateTime(), required=False, help="Date time from" -) -@click.option( - "--to", "to_dt", type=click.DateTime(), required=False, help="Date time to" -) +@application.command(name="remove") +@click.argument("APPLICATION_ID", type=str, required=True) @pass_latch_sdk -def history( - latch: LatchSDK, account_id: str, from_dt: datetime | None, to_dt: datetime | None +def remove( + latch: LatchSDK, + application_id: str, ): - """Show latch actions history""" - history = latch.history(account_id, from_dt=from_dt, to_dt=to_dt) + """Remove a given application""" + latch.application_remove( + application_id=application_id, + ) - render_history_response(history) + click.secho("Application removed", bg="green") diff --git a/src/latch_sdk/cli/instance.py b/src/latch_sdk/cli/instance.py index 5381921..bce3ad5 100644 --- a/src/latch_sdk/cli/instance.py +++ b/src/latch_sdk/cli/instance.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -59,7 +58,7 @@ def create( ): """Create a new instance""" instance_id = latch.instance_create( - name, ctx.obj["account_id"], operation_id=ctx.obj["operation_id"] + ctx.obj["account_id"], name, operation_id=ctx.obj["operation_id"] ) click.secho(f"Created new instance with id: {instance_id}", bg="green") @@ -94,8 +93,8 @@ def update( ): """Update a given instance""" latch.instance_update( - instance_id, ctx.obj["account_id"], + instance_id, operation_id=ctx.obj["operation_id"], name=name, two_factor=two_factor, @@ -116,8 +115,8 @@ def remove( ): """Remove a given instance""" latch.instance_remove( - instance_id, ctx.obj["account_id"], + instance_id, operation_id=ctx.obj["operation_id"], ) diff --git a/src/latch_sdk/cli/operation.py b/src/latch_sdk/cli/operation.py index 7ad1e8d..e239908 100644 --- a/src/latch_sdk/cli/operation.py +++ b/src/latch_sdk/cli/operation.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/src/latch_sdk/cli/renders.py b/src/latch_sdk/cli/renders.py index 055bf7d..a64aba2 100644 --- a/src/latch_sdk/cli/renders.py +++ b/src/latch_sdk/cli/renders.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -21,10 +20,14 @@ import click from ..models import ( + TOTP, Application, + ApplicationCreateResponse, + ApplicationHistory, Client, HistoryEntry, HistoryResponse, + Identity, Instance, Operation, Status, @@ -78,7 +81,7 @@ def render_operation(operation: Operation, indent=""): def render_history_response(history: HistoryResponse): - render_application(history.application) + render_application_history(history.application) click.echo(f"Last seen: {history.last_seen}") render_clients(history.client_version) @@ -99,7 +102,7 @@ def render_client(client: Client, indent=""): click.echo(f"{indent}Version: {client.app}") -def render_application(app: Application, indent=""): +def render_application_history(app: ApplicationHistory, indent=""): click.echo(f"{indent}{app.name}") click.echo(f"{indent}{app.description}") click.echo(f"{indent}Identifier: {app.application_id}") @@ -150,3 +153,60 @@ def render_instance(instance: Instance, indent=""): click.echo(f"{indent}Instance ID: {instance.instance_id}") click.echo(f"{indent}Two factor: {instance.two_factor.value}") click.echo(f"{indent}Lock on request: {instance.lock_on_request.value}") + + +def render_identity(identity: Identity, indent=""): + click.echo(f"{indent}ID: {identity.id}") + click.echo(f"{indent}Name: {identity.name}") + + +def render_totp(totp: TOTP, indent=""): + click.echo(f"{indent}TOTP ID: {totp.totp_id}") + click.echo(f"{indent}Issuer: {totp.issuer}") + click.echo(f"{indent}Identity:") + + render_identity(totp.identity, indent=indent + " " * 4) + + click.secho(f"{indent}Secret: {totp.secret}", bg="magenta") + click.echo(f"{indent}Algorithm: {totp.algorithm}") + click.echo(f"{indent}Digits: {totp.digits}") + click.echo(f"{indent}Period: {totp.period}") + click.echo(f"{indent}Created at: {totp.created_at}") + if totp.disabled_by_subscription_limit: + click.secho(f"{indent}Disabled because subription limit raised", bg="red") + else: + click.secho(f"{indent}Enabled, subription limit not raised", bg="green") + click.echo(f"{indent}URI: {totp.uri}") + click.echo(f"{indent}QR: {totp.qr}") + + +def render_application_create_response(app: ApplicationCreateResponse, indent=""): + click.echo(f"{indent}Identifier: {app.application_id}") + click.secho(f"{indent}Secret: {app.secret}", bg="magenta") + + +def render_applications(applications: Iterable[Application]): + click.echo("Applications:") + click.echo("-" * 50) + for app in applications: + render_application(app) + click.echo("-" * 50) + + +def render_application(app: Application, indent=""): + click.echo(f"{indent}{app.name}") + click.echo(f"{indent}{app.description}") + click.echo(f"{indent}Identifier: {app.application_id}") + click.echo(f"{indent}Image URL: {app.image_url}") + click.secho(f"{indent}Secret: {app.secret}", bg="magenta") + click.echo(f"{indent}Contact email: {app.contact_mail}") + click.echo(f"{indent}Contact phone: {app.contact_phone}") + click.echo(f"{indent}Two factor: {app.two_factor.value}") + click.echo(f"{indent}Lock on request: {app.lock_on_request.value}") + + if app.operations: + click.echo(f"{indent}Operations:") + click.echo(indent + " |" + "-" * 50) + for op in app.operations: + render_operation(op, indent=indent + " |") + click.echo(indent + " |" + "-" * 50) diff --git a/src/latch_sdk/cli/totp.py b/src/latch_sdk/cli/totp.py new file mode 100644 index 0000000..eb5680d --- /dev/null +++ b/src/latch_sdk/cli/totp.py @@ -0,0 +1,101 @@ +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +import click + +from .renders import ( + render_totp, +) +from .utils import pass_latch_sdk +from ..syncio import LatchSDK + + +@click.group() +def totp(): + """TOTP actions""" + + +@totp.command(name="create") +@click.option( + "--user-id", + "-u", + type=str, + required=True, + help="User identifier", +) +@click.option("--common-name", "-n", type=str, required=True, help="Common name") +@pass_latch_sdk +def create( + latch: LatchSDK, + user_id: str, + common_name: str, +): + """Create a new TOTP""" + totp = latch.totp_create( + user_id=user_id, + common_name=common_name, + ) + + render_totp(totp) + + +@totp.command(name="validate") +@click.argument("TOTP_ID", type=str, required=True) +@click.option("--code", "-c", type=str, required=True, help="Temporal code") +@pass_latch_sdk +def validate( + latch: LatchSDK, + totp_id: str, + code: str, +): + """Validate TOTP code""" + latch.totp_validate( + totp_id=totp_id, + code=code, + ) + + click.secho("TOTP code validated", bg="green") + + +@totp.command(name="load") +@click.argument("TOTP_ID", type=str, required=True) +@pass_latch_sdk +def load( + latch: LatchSDK, + totp_id: str, +): + """Load a given TOTP""" + totp = latch.totp_load( + totp_id=totp_id, + ) + + render_totp(totp) + + +@totp.command(name="remove") +@click.argument("TOTP_ID", type=str, required=True) +@pass_latch_sdk +def remove( + latch: LatchSDK, + totp_id: str, +): + """Remove a given TOTP""" + latch.totp_remove( + totp_id=totp_id, + ) + + click.secho("TOTP removed", bg="green") diff --git a/src/latch_sdk/cli/utils.py b/src/latch_sdk/cli/utils.py index 7a9a0ab..fb55c87 100644 --- a/src/latch_sdk/cli/utils.py +++ b/src/latch_sdk/cli/utils.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py index 84a413b..5faed07 100644 --- a/src/latch_sdk/exceptions.py +++ b/src/latch_sdk/exceptions.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index 315e37e..bccf46e 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -253,6 +252,29 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) +@dataclass(frozen=True) +class ApplicationCreateResponse: + """ + Latch Application create response + """ + + #: Application identifier. + application_id: str + + #: Application secret. + secret: str + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + + data.update(kwargs) + + return cls(application_id=data["applicationId"], secret=data["secret"]) + + @dataclass(frozen=True) class Application: """ @@ -271,6 +293,83 @@ class Application: #: URL to application image. image_url: str + #: Application secret. + secret: str + + #: Minutes to close latch after open. 0 means no autoclose. + autoclose: int + + #: Contact email. + contact_mail: "str | None" = None + + #: Contact phone number. + contact_phone: "str | None" = None + + #: State of `Two factor` feature. + two_factor: ExtraFeature = ExtraFeature.DISABLED + + #: State of `Lock on request` feature. + lock_on_request: ExtraFeature = ExtraFeature.DISABLED + + #: List of descendant operations. + operations: "Iterable[Operation] | None" = None + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + + data.update(kwargs) + + return cls( + application_id=data["application_id"], + name=data["name"], + description=data.get("description", ""), + secret=data["secret"], + image_url=data["imageUrl"], + contact_mail=data.get("contactEmail", ""), + contact_phone=data.get("contactPhone", ""), + two_factor=( + ExtraFeature(data["two_factor"]) + if "two_factor" in data + else ExtraFeature.DISABLED + ), + lock_on_request=( + ExtraFeature(data["lock_on_request"]) + if "lock_on_request" in data + else ExtraFeature.DISABLED + ), + autoclose=data.get("autoclose", 0), + operations=( + [ + Operation.build_from_dict(d, operation_id=i) + for i, d in data["operations"].items() + ] + if "operations" in data + else None + ), + ) + + +@dataclass(frozen=True) +class ApplicationHistory: + """ + Latch Application information + """ + + #: Application identifier. + application_id: str + + #: Application name. + name: str + + #: Application description. + description: str + + #: URL to application image. + image_url: str + #: Application current state. status: bool @@ -360,7 +459,7 @@ class HistoryResponse: """ #: Application information. - application: Application + application: ApplicationHistory #: List of client used by user. client_version: Iterable[Client] @@ -387,7 +486,7 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) return cls( - application=Application.build_from_dict( + application=ApplicationHistory.build_from_dict( data[app_id], application_id=app_id ), client_version=[Client(**d) for d in data["clientVersion"]], @@ -401,6 +500,61 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": ) +@dataclass(frozen=True) +class Identity: + id: str + name: str + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + + data.update(kwargs) + + return cls(id=data["id"], name=data["name"]) + + +@dataclass(frozen=True) +class TOTP: + totp_id: str + secret: str + app_id: str + identity: Identity + issuer: str + algorithm: str + digits: int + period: int + created_at: datetime + disabled_by_subscription_limit: bool + qr: str + uri: str + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + + data.update(kwargs) + + return cls( + totp_id=data["totpId"], + secret=data["secret"], + app_id=data["appId"], + identity=Identity.build_from_dict(data["identity"]), + issuer=data["issuer"], + algorithm=data["algorithm"], + digits=data["digits"], + period=data["period"], + created_at=datetime.fromisoformat(data["createdAt"]), + disabled_by_subscription_limit=data["disabledBySubscriptionLimit"], + qr=data["qr"], + uri=data["uri"], + ) + + class ErrorData(TypedDict): code: int message: str diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index ac0e182..3796ccf 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -34,7 +33,17 @@ from urllib.parse import urlparse from .exceptions import ApplicationAlreadyPaired, BaseLatchException -from .models import ExtraFeature, HistoryResponse, Instance, Operation, Response, Status +from .models import ( + TOTP, + Application, + ApplicationCreateResponse, + ExtraFeature, + HistoryResponse, + Instance, + Operation, + Response, + Status, +) try: from collections.abc import Mapping @@ -70,6 +79,7 @@ class Paths(TypedDict): subscription: str application: str instance: str + totp: str class LatchSansIO(ABC, Generic[TResponse]): @@ -84,13 +94,14 @@ class LatchSansIO(ABC, Generic[TResponse]): API_PATH_SUBSCRIPTION_PATTERN = "/api/${version}/subscription" API_PATH_APPLICATION_PATTERN = "/api/${version}/application" API_PATH_INSTANCE_PATTERN = "/api/${version}/instance" + API_PATH_TOTP_PATTERN = "/api/${version}/totps" AUTHORIZATION_HEADER_NAME = "Authorization" DATE_HEADER_NAME = "X-11Paths-Date" AUTHORIZATION_METHOD = "11PATHS" AUTHORIZATION_HEADER_FIELD_SEPARATOR = " " - UTC_STRING_FORMAT = "%Y-%m-%d %H:%M:%S" + DATE_HEADER_FORMAT = "%Y-%m-%d %H:%M:%S" X_11PATHS_HEADER_PREFIX = "X-11paths-" X_11PATHS_HEADER_SEPARATOR = ":" @@ -100,8 +111,8 @@ def __init__( app_id: str, secret_key: str, *, - api_version: str = "1.0", - host: str = "latch.telefonica.com", + api_version: str = "3.0", + host: str = "latch.tu.com", port: int = 443, is_https: bool = True, proxy_host: "str | None" = None, @@ -245,6 +256,9 @@ def build_paths(cls, api_version: str) -> Paths: "instance": Template(cls.API_PATH_INSTANCE_PATTERN).safe_substitute( {"version": api_version} ), + "totp": Template(cls.API_PATH_TOTP_PATTERN).safe_substitute( + {"version": api_version} + ), } @classmethod @@ -266,6 +280,8 @@ def get_part_from_header(cls, part: int, header: str) -> str: @classmethod def get_auth_method_from_header(cls, authorization_header: str) -> str: """ + Get authorization method from header. + :param authorization_header: Authorization HTTP Header :return: The Authorization method. Typical values are "Basic", "Digest" or "11PATHS" """ @@ -275,6 +291,8 @@ def get_auth_method_from_header(cls, authorization_header: str) -> str: @classmethod def get_app_id_from_header(cls, authorization_header: str) -> str: """ + Get application identifier from header. + :param authorization_header: Authorization HTTP Header :return: The requesting application Id. Identifies the application using the API """ @@ -283,23 +301,19 @@ def get_app_id_from_header(cls, authorization_header: str) -> str: @classmethod def get_signature_from_header(cls, authorization_header: str) -> str: """ + Get signature from header. + :param authorization_header: Authorization HTTP Header :return: The signature of the current request. Verifies the identity of the application using the API """ return cls.get_part_from_header(2, authorization_header) - @classmethod - def get_current_UTC(cls) -> str: - """ - :return: a string representation of the current time in UTC to be used in a Date HTTP Header - """ - return datetime.now(tz=timezone.utc).strftime(cls.UTC_STRING_FORMAT) - def sign_data(self, data: str) -> str: """ + Sign data using configured secret. + :param data: the string to sign - :return: base64 encoding of the HMAC-SHA1 hash of the data parameter using - {@code secretKey} as cipher key. + :return: base64 encoding of the HMAC-SHA1 hash of the data parameter using as cipher key. """ from .utils import sign_data @@ -308,21 +322,24 @@ def sign_data(self, data: str) -> str: def authentication_headers( self, http_method: str, - query_string: str, - x_headers: "Mapping[str, str] | None" = None, - utc: "str | None" = None, + path_and_query: str, + *, + headers: "Mapping[str, str] | None" = None, + dt: "datetime | None" = None, params: "Mapping[str, str] | None" = None, ) -> dict[str, str]: """ Calculate the authentication headers to be sent with a request to the API :param http_method: the HTTP Method, currently only GET is supported - :param query_string: the urlencoded string including the path (from the first forward slash) and the parameters - :param x_headers: HTTP headers specific to the 11-paths API. null if not needed. - :param utc: the Universal Coordinated Time for the Date HTTP header + :param path_and_query: the urlencoded string including the path + (from the first forward slash) and the parameters + :param headers: HTTP headers specific to the 11-paths API. null if not needed. + :param dt: the Universal Coordinated Time datetime. + :param params: Request parameters to serialize on body. :return: A map with the Authorization and Date headers needed to sign a Latch API request """ - utc = utc or self.get_current_UTC() + dt_str = (dt or datetime.now(tz=timezone.utc)).strftime(self.DATE_HEADER_FORMAT) authorization_header = self.AUTHORIZATION_HEADER_FIELD_SEPARATOR.join( [ @@ -330,7 +347,11 @@ def authentication_headers( self.app_id, self.sign_data( self.build_data_to_sign( - http_method, utc, query_string, x_headers, params + http_method, + dt_str, + path_and_query, + headers=headers, + params=params, ) ), ] @@ -338,21 +359,31 @@ def authentication_headers( return { self.AUTHORIZATION_HEADER_NAME: authorization_header, - self.DATE_HEADER_NAME: utc, + self.DATE_HEADER_NAME: dt_str, } @classmethod def build_data_to_sign( cls, method: str, - utc: str, + dt_str: str, path_and_query: str, + *, headers: "Mapping[str, str] | None" = None, params: "Mapping[str, str] | None" = None, ) -> str: + """ + Build string data to sign. + + :param method: HTTP method. + :param dt_str: Current UTC datetime string. + :param path_and_query: Path and query string. + :param headers: Request headers considered (with X-11Path- prefix). + :param params: Form parameters. + """ parts: list[str] = [ method.upper().strip(), - utc.strip(), + dt_str.strip(), cls.get_serialized_headers(headers), path_and_query.strip(), ] @@ -366,7 +397,8 @@ def build_data_to_sign( def get_serialized_headers(cls, headers: "Mapping[str, str] | None") -> str: """ Prepares and returns a string ready to be signed from the 11-paths specific HTTP headers received - :param x_headers: a non neccesarily ordered map (array without duplicates) of the HTTP headers to be ordered. + + :param headers: a non neccesarily ordered map (array without duplicates) of the HTTP headers to be ordered. :return: string The serialized headers, an empty string if no headers are passed, or None if there's a problem such as non 11paths specific headers """ @@ -392,6 +424,11 @@ def get_serialized_headers(cls, headers: "Mapping[str, str] | None") -> str: @classmethod def get_serialized_params(cls, params: Mapping[str, str]) -> str: + """ + Get paramaters serialized. + + :param params: Parameters to serialize. + """ from urllib.parse import urlencode return urlencode(sorted(params.items()), doseq=True) @@ -399,7 +436,13 @@ def get_serialized_params(cls, params: Mapping[str, str]) -> str: def _reconfigure_session(self): # pragma: no cover pass - def build_url(self, path: str, query: "str | None" = None) -> str: + def build_url(self, path: str, *, query: "str | None" = None) -> str: + """ + Build URL from path and query string. + + :param path: URL path + :param query: Query parameters string + """ from urllib.parse import urlunparse netloc = self.host @@ -416,10 +459,13 @@ def _prepare_http( self, method: str, path: str, - x_headers: "Mapping[str, str] | None" = None, + *, + headers: "Mapping[str, str] | None" = None, params: "Mapping[str, str] | None" = None, ) -> TResponse: - headers = self.authentication_headers(method, path, x_headers, None, params) + headers = self.authentication_headers( + method, path, headers=headers, params=params + ) if method == "POST" or method == "PUT": headers["Content-type"] = "application/x-www-form-urlencoded" @@ -435,7 +481,7 @@ def _http( ) -> TResponse: # pragma: no cover raise NotImplementedError("Client http must be implemented") - def pair_with_id( + def account_pair_with_id( self, account_id: str, *, @@ -455,9 +501,9 @@ def pair_with_id( return self._prepare_http("GET", path) params = {"wallet": web3_account, "signature": web3_signature} - return self._prepare_http("POST", path, None, params) + return self._prepare_http("POST", path, params=params) - def pair( + def account_pair( self, token: str, *, @@ -477,9 +523,9 @@ def pair( return self._prepare_http("GET", path) params = {"wallet": web3_account, "signature": web3_signature} - return self._prepare_http("POST", path, None, params) + return self._prepare_http("POST", path, params=params) - def status( + def account_status( self, account_id: str, *, @@ -513,7 +559,7 @@ def status( return self._prepare_http("GET", "/".join(parts)) - def unpair(self, account_id: str) -> TResponse: + def account_unpair(self, account_id: str) -> TResponse: """ Unpairs the origin provider with a user account. @@ -521,7 +567,7 @@ def unpair(self, account_id: str) -> TResponse: """ return self._prepare_http("GET", "/".join((self._paths["unpair"], account_id))) - def lock( + def account_lock( self, account_id: str, *, @@ -545,7 +591,7 @@ def lock( return self._prepare_http("POST", "/".join(parts)) - def unlock( + def account_unlock( self, account_id: str, *, @@ -569,7 +615,7 @@ def unlock( return self._prepare_http("POST", "/".join(parts)) - def history( + def account_history( self, account_id: str, *, @@ -621,7 +667,7 @@ def operation_create( "two_factor": two_factor.value, "lock_on_request": lock_on_request.value, } - return self._prepare_http("PUT", self._paths["operation"], None, params) + return self._prepare_http("PUT", self._paths["operation"], params=params) def operation_update( self, @@ -653,7 +699,7 @@ def operation_update( raise ValueError("No new data to update") return self._prepare_http( - "POST", "/".join((self._paths["operation"], operation_id)), None, params + "POST", "/".join((self._paths["operation"], operation_id)), params=params ) def operation_remove(self, operation_id: str): @@ -694,13 +740,13 @@ def instance_list(self, account_id: str, *, operation_id: "str | None" = None): return self._prepare_http("GET", "/".join(parts)) def instance_create( - self, name: str, account_id: str, *, operation_id: "str | None" = None + self, account_id: str, name: str, *, operation_id: "str | None" = None ): """ Create an instance. - :param name: The name of the instance. :param account_id: The account identifier. + :param name: The name of the instance. :param operation_id: The operation identifier. """ # Only one at a time @@ -714,14 +760,13 @@ def instance_create( return self._prepare_http( "PUT", "/".join(parts), - None, - params, + params=params, ) def instance_update( self, - instance_id: str, account_id: str, + instance_id: str, *, operation_id: "str | None" = None, name: "str | None" = None, @@ -763,12 +808,11 @@ def instance_update( return self._prepare_http( "POST", "/".join(parts), - None, - params, + params=params, ) def instance_remove( - self, instance_id: str, account_id: str, operation_id: "str | None" = None + self, account_id: str, instance_id: str, operation_id: "str | None" = None ): """ Remove the instance. @@ -786,6 +830,142 @@ def instance_remove( return self._prepare_http("DELETE", "/".join(parts)) + def totp_create(self, user_id: str, common_name: str): + """ + Create a Time-based one-time password. + + :param user_id: User identifier (mail) + :param common_name: Name for the Totp + """ + return self._prepare_http( + "POST", + self._paths["totp"], + params={"userId": user_id, "commonName": common_name}, + ) + + def totp_load(self, totp_id: str): + """ + Get data information about a given totp. + + :param totp_id: Totp Identifier + """ + return self._prepare_http("GET", "/".join([self._paths["totp"], totp_id])) + + def totp_validate(self, totp_id: str, code: str): + """ + Validate a code from a totp + + :param totp_id: Totp Identifier + :param code: Code generated + """ + return self._prepare_http( + "POST", + "/".join([self._paths["totp"], totp_id, "validate"]), + params={"code": code}, + ) + + def totp_remove(self, totp_id: str): + """ + Remove a totp + + :param totp_id: Totp Identifier + """ + return self._prepare_http("DELETE", "/".join([self._paths["totp"], totp_id])) + + def application_create( + self, + name: str, + *, + two_factor: ExtraFeature = ExtraFeature.DISABLED, + lock_on_request: ExtraFeature = ExtraFeature.DISABLED, + contact_phone: str = "", + contact_mail: str = "", + ): + """ + Create a new application + + :param name: Name of new application + :param two_factor: Specifies if the `Two Factor` protection is enabled for this instance. + :param lock_on_request: Specifies if the `Lock latches on status request` feature is disabled, + opt-in or mandatory for this instance. + :param contact_phone: Contact phone number. + :param contact_mail: Contact email. + """ + return self._prepare_http( + "PUT", + self._paths["application"], + params={ + "name": name, + "two_factor": two_factor.value, + "lock_on_request": lock_on_request.value, + "contactMail": contact_mail, + "contactPhone": contact_phone, + }, + ) + + def application_list(self): + """ + Returns the list of registered applications. + """ + return self._prepare_http("GET", self._paths["application"]) + + def application_update( + self, + application_id: str, + *, + name: "str | None" = None, + two_factor: "ExtraFeature | None" = None, + lock_on_request: "ExtraFeature | None" = None, + contact_phone: "str | None" = None, + contact_mail: "str | None" = None, + ): + """ + Modify a given application + + :param application_id: Application identifier + :param name: Name of new application + :param two_factor: Specifies if the `Two Factor` protection is enabled for this instance. + :param lock_on_request: Specifies if the `Lock latches on status request` feature is disabled, + opt-in or mandatory for this instance. + :param contact_phone: Contact phone number. + :param contact_mail: Contact email. + """ + params: dict[str, str] = {} + + if name: + params["name"] = name + + if two_factor: + params["two_factor"] = two_factor.value + + if lock_on_request: + params["lock_on_request"] = lock_on_request + + if contact_phone is not None: + params["contactPhone"] = contact_phone + + if contact_mail is not None: + params["contactMail"] = contact_mail + + if len(params) == 0: + raise ValueError("No update data") + + return self._prepare_http( + "POST", + "/".join((self._paths["application"], application_id)), + params=params, + ) + + def application_remove(self, application_id: str): + """ + Remove a given application. + + :param application_id: Application identifier + """ + return self._prepare_http( + "DELETE", "/".join((self._paths["application"], application_id)) + ) + TFactory: "TypeAlias" = Callable[[Response], "TReturnType"] @@ -803,7 +983,16 @@ def check_error(resp: Response): raise BaseLatchException(resp.error.code, resp.error.message) -def response_pair(resp: Response) -> str: +def response_no_error(resp: Response) -> Literal[True]: + """ + Returns `True` if not error + """ + check_error(resp) + + return True + + +def response_account_pair(resp: Response) -> str: """ Gets accountId from response """ @@ -819,26 +1008,28 @@ def response_pair(resp: Response) -> str: return cast(str, resp.data["accountId"]) -def response_no_error(resp: Response) -> Literal[True]: +def response_account_status(resp: Response) -> Status: """ - Returns `True` if not error + Builds status object from response """ check_error(resp) - return True + assert resp.data is not None, "No error or data" + op_id, status_data = cast(dict[str, dict], resp.data["operations"]).popitem() + + return Status.build_from_dict(status_data, operation_id=op_id) -def response_status(resp: Response) -> Status: + +def response_account_history(resp: Response) -> HistoryResponse: """ - Builds status object from response + Builds history response object from response """ check_error(resp) assert resp.data is not None, "No error or data" - op_id, status_data = cast(dict[str, dict], resp.data["operations"]).popitem() - - return Status.build_from_dict(status_data, operation_id=op_id) + return HistoryResponse.build_from_dict(resp.data) def response_operation(resp: Response) -> Operation: @@ -885,7 +1076,7 @@ def response_instance_list(resp: Response) -> Iterable[Instance]: return [Instance.build_from_dict(d, instance_id=i) for i, d in resp.data.items()] -def response_add_instance(resp: Response) -> str: +def response_instance_create(resp: Response) -> str: """ Gets instance identifier from response """ @@ -896,12 +1087,37 @@ def response_add_instance(resp: Response) -> str: return next(iter(resp.data.keys())) -def response_history(resp: Response) -> HistoryResponse: +def response_totp(resp: Response) -> TOTP: """ - Builds history response object from response + Gets TOTP object from response """ check_error(resp) assert resp.data is not None, "No error or data" - return HistoryResponse.build_from_dict(resp.data) + return TOTP.build_from_dict(resp.data) + + +def response_application_create(resp: Response) -> ApplicationCreateResponse: + """ + Application creation information from response + """ + check_error(resp) + + assert resp.data is not None, "No error or data" + + return ApplicationCreateResponse.build_from_dict(resp.data) + + +def response_application_list(resp: Response) -> Iterable[Application]: + """ + Builds application object list from response + """ + check_error(resp) + + assert resp.data is not None, "No error or data" + + return [ + Application.build_from_dict(d, application_id=i) + for i, d in resp.data.get("operations", {}).items() + ] diff --git a/src/latch_sdk/syncio/__init__.py b/src/latch_sdk/syncio/__init__.py index 32cd5d0..d1feb54 100644 --- a/src/latch_sdk/syncio/__init__.py +++ b/src/latch_sdk/syncio/__init__.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index d893e1c..5701e97 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -25,14 +24,17 @@ P, TFactory, TReturnType, - response_add_instance, - response_history, + response_account_history, + response_account_pair, + response_account_status, + response_application_create, + response_application_list, + response_instance_create, response_instance_list, response_no_error, response_operation, response_operation_list, - response_pair, - response_status, + response_totp, ) from ..utils import wraps_and_replace_return @@ -64,20 +66,20 @@ class LatchSDK(LatchSDKSansIO[Response]): Latch SDK synchronous main class. """ - pair = wrap_method(response_pair, BaseLatch.pair) - pair_with_id = wrap_method( - response_pair, - BaseLatch.pair_with_id, + account_pair = wrap_method(response_account_pair, BaseLatch.account_pair) + account_pair_with_id = wrap_method( + response_account_pair, + BaseLatch.account_pair_with_id, ) - unpair = wrap_method(response_no_error, BaseLatch.unpair) + account_unpair = wrap_method(response_no_error, BaseLatch.account_unpair) - status = wrap_method(response_status, BaseLatch.status) + account_status = wrap_method(response_account_status, BaseLatch.account_status) - lock = wrap_method(response_no_error, BaseLatch.lock) - unlock = wrap_method(response_no_error, BaseLatch.unlock) + account_lock = wrap_method(response_no_error, BaseLatch.account_lock) + account_unlock = wrap_method(response_no_error, BaseLatch.account_unlock) - history = wrap_method(response_history, BaseLatch.history) + account_history = wrap_method(response_account_history, BaseLatch.account_history) operation_list = wrap_method(response_operation_list, BaseLatch.operation_list) operation_create = wrap_method(response_operation, BaseLatch.operation_create) @@ -85,6 +87,20 @@ class LatchSDK(LatchSDKSansIO[Response]): operation_remove = wrap_method(response_no_error, BaseLatch.operation_remove) instance_list = wrap_method(response_instance_list, BaseLatch.instance_list) - instance_create = wrap_method(response_add_instance, BaseLatch.instance_create) + instance_create = wrap_method(response_instance_create, BaseLatch.instance_create) instance_update = wrap_method(response_no_error, BaseLatch.instance_update) instance_remove = wrap_method(response_no_error, BaseLatch.instance_remove) + + totp_load = wrap_method(response_totp, BaseLatch.totp_load) + totp_create = wrap_method(response_totp, BaseLatch.totp_create) + totp_validate = wrap_method(response_no_error, BaseLatch.totp_validate) + totp_remove = wrap_method(response_no_error, BaseLatch.totp_remove) + + application_list = wrap_method( + response_application_list, BaseLatch.application_list + ) + application_create = wrap_method( + response_application_create, BaseLatch.application_create + ) + application_update = wrap_method(response_no_error, BaseLatch.application_update) + application_remove = wrap_method(response_no_error, BaseLatch.application_remove) diff --git a/src/latch_sdk/syncio/httpx.py b/src/latch_sdk/syncio/httpx.py index 0d6afd0..f57a0c6 100644 --- a/src/latch_sdk/syncio/httpx.py +++ b/src/latch_sdk/syncio/httpx.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -81,4 +80,4 @@ def _http( response.raise_for_status() - return Response(response.json()) + return Response(response.json() if response.text else {}) diff --git a/src/latch_sdk/syncio/pure.py b/src/latch_sdk/syncio/pure.py index a0a3f92..abb3190 100644 --- a/src/latch_sdk/syncio/pure.py +++ b/src/latch_sdk/syncio/pure.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -88,6 +87,6 @@ def _http( response_data = response.read().decode("utf8") - return Response(json.loads(response_data)) + return Response(json.loads(response_data) if response_data else {}) finally: conn.close() diff --git a/src/latch_sdk/syncio/requests.py b/src/latch_sdk/syncio/requests.py index 4b395d3..c64560a 100644 --- a/src/latch_sdk/syncio/requests.py +++ b/src/latch_sdk/syncio/requests.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -83,4 +82,4 @@ def _http( response.raise_for_status() - return Response(response.json()) + return Response(response.json() if response.text else {}) diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index f0bb51c..c408176 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/tests/asyncio/__init__.py b/tests/asyncio/__init__.py index a1a351c..cb1b655 100644 --- a/tests/asyncio/__init__.py +++ b/tests/asyncio/__init__.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/tests/asyncio/test_aiohttp.py b/tests/asyncio/test_aiohttp.py index 6f0bd64..1b48d74 100644 --- a/tests/asyncio/test_aiohttp.py +++ b/tests/asyncio/test_aiohttp.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -54,20 +53,20 @@ async def test_http_get( client_mock: Mock, ): instance_mock, response_mock = _prepare_mocks( - client_mock, ResponseFactory.status_on() + client_mock, ResponseFactory.account_status_on() ) latch = Latch(self.API_ID, self.SECRET) latch.host = "http://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() response_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -81,14 +80,14 @@ async def test_http_post( client_mock: Mock, ): instance_mock, response_mock = _prepare_mocks( - client_mock, ResponseFactory.pair() + client_mock, ResponseFactory.account_pair() ) latch = Latch(self.API_ID, self.SECRET) latch.host = "http://foo.bar.com" - await latch.pair( + await latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -96,7 +95,7 @@ async def test_http_post( response_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "POST", - "http://foo.bar.com/api/1.0/pair/pinnnn", + "http://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -111,20 +110,20 @@ async def test_https_get( client_mock: Mock, ): instance_mock, response_mock = _prepare_mocks( - client_mock, ResponseFactory.status_on() + client_mock, ResponseFactory.account_status_on() ) latch = Latch(self.API_ID, self.SECRET) latch.host = "https://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() response_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + "https://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -138,14 +137,14 @@ async def test_https_post( client_mock: Mock, ): instance_mock, response_mock = _prepare_mocks( - client_mock, ResponseFactory.pair() + client_mock, ResponseFactory.account_pair() ) latch = Latch(self.API_ID, self.SECRET) latch.host = "https://foo.bar.com" - await latch.pair( + await latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -153,7 +152,7 @@ async def test_https_post( response_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "POST", - "https://foo.bar.com/api/1.0/pair/pinnnn", + "https://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -168,7 +167,7 @@ async def test_proxy_http_get( client_mock: Mock, ): instance_mock, response_mock = _prepare_mocks( - client_mock, ResponseFactory.status_on() + client_mock, ResponseFactory.account_status_on() ) latch = Latch(self.API_ID, self.SECRET) @@ -178,13 +177,13 @@ async def test_proxy_http_get( client_mock.assert_called_with(proxy="http://proxy.bar.com:8443") latch.host = "http://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() response_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -198,7 +197,7 @@ async def test_proxy_no_port_http_get( client_mock: Mock, ): instance_mock, response_mock = _prepare_mocks( - client_mock, ResponseFactory.status_on() + client_mock, ResponseFactory.account_status_on() ) latch = Latch(self.API_ID, self.SECRET) @@ -208,13 +207,13 @@ async def test_proxy_no_port_http_get( client_mock.assert_called_with(proxy="http://proxy.bar.com") latch.host = "http://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() response_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, diff --git a/tests/asyncio/test_base.py b/tests/asyncio/test_base.py index b2177b9..6391128 100644 --- a/tests/asyncio/test_base.py +++ b/tests/asyncio/test_base.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -20,7 +19,7 @@ from unittest.mock import AsyncMock from latch_sdk.asyncio.base import BaseLatch, LatchSDK -from latch_sdk.exceptions import ApplicationAlreadyPaired, TokenNotFound +from latch_sdk.exceptions import ApplicationAlreadyPaired, InvalidTOTP, TokenNotFound from latch_sdk.models import Response from ..factory import ResponseFactory @@ -36,24 +35,115 @@ def setUp(self) -> None: return super().setUp() - async def test_pair(self): - self.core.pair.return_value = Response(ResponseFactory.pair("test_account")) - self.assertEqual(await self.latch_sdk.pair("terwrw"), "test_account") + async def test_account_pair(self): + self.core.account_pair.return_value = Response( + ResponseFactory.account_pair("test_account") + ) + self.assertEqual(await self.latch_sdk.account_pair("terwrw"), "test_account") - async def test_pair_error_206_token_expired(self): - self.core.pair.return_value = Response( - ResponseFactory.pair_error_206_token_expired() + async def test_account_pair_error_206_token_expired(self): + self.core.account_pair.return_value = Response( + ResponseFactory.account_pair_error_206_token_expired() ) with self.assertRaises(TokenNotFound): - await self.latch_sdk.pair("terwrw") + await self.latch_sdk.account_pair("terwrw") - async def test_pair_error_205_already_paired(self): - self.core.pair.return_value = Response( - ResponseFactory.pair_error_205_already_paired("test_account") + async def test_account_pair_error_205_already_paired(self): + self.core.account_pair.return_value = Response( + ResponseFactory.account_pair_error_205_already_paired("test_account") ) with self.assertRaises(ApplicationAlreadyPaired) as ex: - await self.latch_sdk.pair("terwrw") + await self.latch_sdk.account_pair("terwrw") self.assertEqual(ex.exception.account_id, "test_account") # type: ignore + + async def test_totp_create(self): + user_id = "example@tu.com" + common_name = "Example Test" + self.core.totp_create.return_value = Response( + ResponseFactory.totp_create_or_load( + identity_id=user_id, identity_name=common_name + ) + ) + totp = await self.latch_sdk.totp_create(user_id, common_name) + self.assertEqual(totp.identity.id, user_id) + self.assertEqual(totp.identity.name, common_name) + + async def test_totp_load(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + user_id = "example@tu.com" + common_name = "Example Test" + self.core.totp_load.return_value = Response( + ResponseFactory.totp_create_or_load( + totp_id=totp_id, identity_id=user_id, identity_name=common_name + ) + ) + totp = await self.latch_sdk.totp_load(totp_id) + + self.assertEqual(totp.totp_id, totp_id) + self.assertEqual(totp.identity.id, user_id) + self.assertEqual(totp.identity.name, common_name) + + async def test_totp_validate(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + code = "2435465" + + self.core.totp_validate.return_value = Response(ResponseFactory.no_data()) + self.assertTrue(await self.latch_sdk.totp_validate(totp_id, code)) + + async def test_totp_validate_error(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + code = "2435465" + + self.core.totp_validate.return_value = Response( + ResponseFactory.totp_validate_error() + ) + + with self.assertRaises(InvalidTOTP): + await self.latch_sdk.totp_validate(totp_id, code) + + async def test_totp_remove(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + + self.core.totp_remove.return_value = Response(ResponseFactory.no_data()) + self.assertTrue(await self.latch_sdk.totp_remove(totp_id)) + + async def test_application_create(self): + application_id = "12346578798654ds" + name = "Example Test" + self.core.application_create.return_value = Response( + ResponseFactory.application_create(application_id=application_id) + ) + app = await self.latch_sdk.application_create(name) + self.assertEqual(app.application_id, application_id) + self.assertIsNotNone(app.secret) + + async def test_application_update(self): + application_id = "12346578798654ds" + name = "Example Test" + self.core.application_update.return_value = Response(ResponseFactory.no_data()) + self.assertTrue( + await self.latch_sdk.application_update(application_id, name=name) + ) + + async def test_application_remove(self): + application_id = "12346578798654ds" + self.core.application_remove.return_value = Response(ResponseFactory.no_data()) + self.assertTrue(await self.latch_sdk.application_remove(application_id)) + + async def test_application_list(self): + self.core.application_list.return_value = Response( + ResponseFactory.application_list() + ) + app_lst = await self.latch_sdk.application_list() + + has_data = False + for app in app_lst: + self.assertIsNotNone(app.application_id) + self.assertIsNotNone(app.secret) + + has_data = True + + self.assertTrue(has_data, "No applications") diff --git a/tests/asyncio/test_httpx.py b/tests/asyncio/test_httpx.py index 427f2a8..d9489bf 100644 --- a/tests/asyncio/test_httpx.py +++ b/tests/asyncio/test_httpx.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -38,7 +37,7 @@ async def test_http_get( instance_mock = AsyncMock(AsyncClient) response_mock = Mock(Response) - response_mock.json.return_value = ResponseFactory.status_on() + response_mock.json.return_value = ResponseFactory.account_status_on() client_mock.return_value = instance_mock instance_mock.__aenter__.return_value = instance_mock @@ -48,13 +47,13 @@ async def test_http_get( latch.host = "http://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() instance_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -70,7 +69,7 @@ async def test_http_post( instance_mock = AsyncMock(AsyncClient) response_mock = Mock(Response) - response_mock.json.return_value = ResponseFactory.pair() + response_mock.json.return_value = ResponseFactory.account_pair() client_mock.return_value = instance_mock instance_mock.__aenter__.return_value = instance_mock @@ -80,7 +79,7 @@ async def test_http_post( latch.host = "http://foo.bar.com" - await latch.pair( + await latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -88,7 +87,7 @@ async def test_http_post( instance_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "POST", - "http://foo.bar.com/api/1.0/pair/pinnnn", + "http://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -105,7 +104,7 @@ async def test_https_get( instance_mock = AsyncMock(AsyncClient) response_mock = Mock(Response) - response_mock.json.return_value = ResponseFactory.status_on() + response_mock.json.return_value = ResponseFactory.account_status_on() client_mock.return_value = instance_mock instance_mock.__aenter__.return_value = instance_mock @@ -115,13 +114,13 @@ async def test_https_get( latch.host = "https://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() instance_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + "https://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -137,7 +136,7 @@ async def test_https_post( instance_mock = AsyncMock(AsyncClient) response_mock = Mock(Response) - response_mock.json.return_value = ResponseFactory.pair() + response_mock.json.return_value = ResponseFactory.account_pair() client_mock.return_value = instance_mock instance_mock.__aenter__.return_value = instance_mock @@ -147,7 +146,7 @@ async def test_https_post( latch.host = "https://foo.bar.com" - await latch.pair( + await latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -155,7 +154,7 @@ async def test_https_post( instance_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "POST", - "https://foo.bar.com/api/1.0/pair/pinnnn", + "https://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -172,7 +171,7 @@ async def test_proxy_http_get( instance_mock = AsyncMock(AsyncClient) response_mock = Mock(Response) - response_mock.json.return_value = ResponseFactory.status_on() + response_mock.json.return_value = ResponseFactory.account_status_on() client_mock.return_value = instance_mock instance_mock.__aenter__.return_value = instance_mock @@ -185,13 +184,13 @@ async def test_proxy_http_get( client_mock.assert_called_with(proxy="http://proxy.bar.com:8443") latch.host = "http://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() instance_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -207,7 +206,7 @@ async def test_proxy_no_port_http_get( instance_mock = AsyncMock(AsyncClient) response_mock = Mock(Response) - response_mock.json.return_value = ResponseFactory.status_on() + response_mock.json.return_value = ResponseFactory.account_status_on() client_mock.return_value = instance_mock instance_mock.__aenter__.return_value = instance_mock @@ -220,13 +219,13 @@ async def test_proxy_no_port_http_get( client_mock.assert_called_with(proxy="http://proxy.bar.com") latch.host = "http://foo.bar.com" - await latch.status("account_id_3454657656454") + await latch.account_status("account_id_3454657656454") client_mock.assert_called() instance_mock.__aenter__.assert_called_once() instance_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, diff --git a/tests/factory.py b/tests/factory.py index 7ac7bf2..ce4e03a 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -29,14 +28,14 @@ def error(cls, code: int, message: str): return {"error": {"code": code, "message": message}} @classmethod - def pair( + def account_pair( cls, account_id: str = "ngcmkRi38JWiJ8XmeNuDThdcUTYRUfd6ryE9EeRGZdn8zjHXpvFHEzLJpVKguzCw", ): return {"data": {"accountId": account_id}} @classmethod - def pair_error_205_already_paired( + def account_pair_error_205_already_paired( cls, account_id: str = "ngcmkRi38JWiJ8XmeNuDThdcUTYRUfd6ryE9EeRGZdn8zjHXpvFHEzLJpVKguzCw", message: str = "Account and application already paired", @@ -44,11 +43,13 @@ def pair_error_205_already_paired( return {"data": {"accountId": account_id}, **cls.error(205, message)} @classmethod - def pair_error_206_token_expired(cls, message: str = "Token not found or expired"): + def account_pair_error_206_token_expired( + cls, message: str = "Token not found or expired" + ): return cls.error(206, message) @classmethod - def status( + def account_status( cls, status: 'Literal["on"] | Literal["off"]', operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", @@ -56,14 +57,14 @@ def status( return {"data": {"operations": {operation_id: {"status": status}}}} @classmethod - def status_on( + def account_status_on( cls, operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", ): - return cls.status("on", operation_id) + return cls.account_status("on", operation_id) @classmethod - def status_on_two_factor( + def account_status_on_two_factor( cls, operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", token: str = "WsKJdE" ): return { @@ -78,14 +79,14 @@ def status_on_two_factor( } @classmethod - def status_off( + def account_status_off( cls, operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", ): - return cls.status("off", operation_id) + return cls.account_status("off", operation_id) @classmethod - def history(cls): + def account_history(cls): return { "data": { "aD2Gm8s9b9c9CcNEGiWJ": { @@ -178,3 +179,77 @@ def instance_list(cls): }, } } + + @classmethod + def totp_create_or_load( + cls, + *, + totp_id: str = "PPPPPPibsNvWaPJVvy4YhFN3RaffyF3zk4VjYkGBsiGzHaaaaa", + app_id: str = "aaaa8s9b9c9CcNEJJJJ", + identity_id: str = "user@example.com", + identity_name: str = "Example", + issuer: str = "Latch example", + ): + return { + "data": { + "totpId": totp_id, + "secret": "22224444PV6YOKCTQ7YKBQKXEIGDV43VMXPBDLQRQXGGCDARBLUKG6GPDXFAAAAA", + "appId": app_id, + "identity": {"id": identity_id, "name": identity_name}, + "issuer": issuer, + "algorithm": "SHA256", + "digits": 6, + "period": 30, + "createdAt": "2025-02-27T00:00:00Z", + "disabledBySubscriptionLimit": False, + "qr": "data:image/png;base64,iiiiiiiGgoAAAANSUhEUgAAA==", + "uri": ( + "otpauth://totp/user%40example.com?secret=" + "22224444PV6YOKCTQ7YKBQKXEIGDV43VMXPBDLQRQXGGCDAR" + "BLUKG6GPDXFAAAAA&issuer=Latch%20example&algorithm=SHA256&digits=6&period=30" + ), + } + } + + @classmethod + def totp_validate_error(cls): + return {"error": {"code": 306, "message": "Invalid totp code"}} + + @classmethod + def application_create(cls, *, application_id: str = "aaaaaaaaaaaphhhhhhh"): + return { + "data": { + "applicationId": application_id, + "secret": "ggggggp6Mm2cvvU6jECmjAAAAAzxti6Q3PMLHHHH", + } + } + + @classmethod + def application_list(cls): + return { + "data": { + "operations": { + "aaaam8s9b9c9CcJJJJJJ": { + "name": "Test Application", + "two_factor": "DISABLED", + "lock_on_request": "DISABLED", + "secret": "bbbbbbWWWWCCTPVEsLkbbbbbnHPmhMHCVh2ttttt", + "contactPhone": "+34755555555", + "contactEmail": "example@tu.com", + "imageUrl": "https://latchstorage.blob.core.windows.net/pro-custom-images/avatar14.jpg", + "operations": { + "aaaam8s9b9c9CcAAAAAA": { + "name": "test_12", + "two_factor": "DISABLED", + "lock_on_request": "DISABLED", + "operations": {}, + }, + }, + }, + } + } + } + + @classmethod + def application_list_empty(cls): + return {"data": {"operations": {}}} diff --git a/tests/syncio/__init__.py b/tests/syncio/__init__.py index a1a351c..cb1b655 100644 --- a/tests/syncio/__init__.py +++ b/tests/syncio/__init__.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/tests/syncio/test_base.py b/tests/syncio/test_base.py index 9014b28..e0d052a 100644 --- a/tests/syncio/test_base.py +++ b/tests/syncio/test_base.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -19,7 +18,7 @@ from unittest import TestCase from unittest.mock import Mock -from latch_sdk.exceptions import ApplicationAlreadyPaired, TokenNotFound +from latch_sdk.exceptions import ApplicationAlreadyPaired, InvalidTOTP, TokenNotFound from latch_sdk.models import Response from latch_sdk.syncio.base import BaseLatch, LatchSDK @@ -36,24 +35,113 @@ def setUp(self) -> None: return super().setUp() - def test_pair(self): - self.core.pair.return_value = Response(ResponseFactory.pair("test_account")) - self.assertEqual(self.latch_sdk.pair("terwrw"), "test_account") + def test_account_pair(self): + self.core.account_pair.return_value = Response( + ResponseFactory.account_pair("test_account") + ) + self.assertEqual(self.latch_sdk.account_pair("terwrw"), "test_account") - def test_pair_error_206_token_expired(self): - self.core.pair.return_value = Response( - ResponseFactory.pair_error_206_token_expired() + def test_account_pair_error_206_token_expired(self): + self.core.account_pair.return_value = Response( + ResponseFactory.account_pair_error_206_token_expired() ) with self.assertRaises(TokenNotFound): - self.latch_sdk.pair("terwrw") + self.latch_sdk.account_pair("terwrw") - def test_pair_error_205_already_paired(self): - self.core.pair.return_value = Response( - ResponseFactory.pair_error_205_already_paired("test_account") + def test_account_pair_error_205_already_paired(self): + self.core.account_pair.return_value = Response( + ResponseFactory.account_pair_error_205_already_paired("test_account") ) with self.assertRaises(ApplicationAlreadyPaired) as ex: - self.latch_sdk.pair("terwrw") + self.latch_sdk.account_pair("terwrw") self.assertEqual(ex.exception.account_id, "test_account") # type: ignore + + def test_totp_create(self): + user_id = "example@tu.com" + common_name = "Example Test" + self.core.totp_create.return_value = Response( + ResponseFactory.totp_create_or_load( + identity_id=user_id, identity_name=common_name + ) + ) + totp = self.latch_sdk.totp_create(user_id, common_name) + self.assertEqual(totp.identity.id, user_id) + self.assertEqual(totp.identity.name, common_name) + + def test_totp_load(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + user_id = "example@tu.com" + common_name = "Example Test" + self.core.totp_load.return_value = Response( + ResponseFactory.totp_create_or_load( + totp_id=totp_id, identity_id=user_id, identity_name=common_name + ) + ) + totp = self.latch_sdk.totp_load(totp_id) + + self.assertEqual(totp.totp_id, totp_id) + self.assertEqual(totp.identity.id, user_id) + self.assertEqual(totp.identity.name, common_name) + + def test_totp_validate(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + code = "2435465" + + self.core.totp_validate.return_value = Response(ResponseFactory.no_data()) + self.assertTrue(self.latch_sdk.totp_validate(totp_id, code)) + + def test_totp_validate_error(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + code = "2435465" + + self.core.totp_validate.return_value = Response( + ResponseFactory.totp_validate_error() + ) + + with self.assertRaises(InvalidTOTP): + self.latch_sdk.totp_validate(totp_id, code) + + def test_totp_remove(self): + totp_id = "435yujrhtgefqw34restghjdrsyetsfd" + + self.core.totp_remove.return_value = Response(ResponseFactory.no_data()) + self.assertTrue(self.latch_sdk.totp_remove(totp_id)) + + def test_application_create(self): + application_id = "12346578798654ds" + name = "Example Test" + self.core.application_create.return_value = Response( + ResponseFactory.application_create(application_id=application_id) + ) + app = self.latch_sdk.application_create(name) + self.assertEqual(app.application_id, application_id) + self.assertIsNotNone(app.secret) + + def test_application_update(self): + application_id = "12346578798654ds" + name = "Example Test" + self.core.application_update.return_value = Response(ResponseFactory.no_data()) + self.assertTrue(self.latch_sdk.application_update(application_id, name=name)) + + def test_application_remove(self): + application_id = "12346578798654ds" + self.core.application_remove.return_value = Response(ResponseFactory.no_data()) + self.assertTrue(self.latch_sdk.application_remove(application_id)) + + def test_application_list(self): + self.core.application_list.return_value = Response( + ResponseFactory.application_list() + ) + app_lst = self.latch_sdk.application_list() + + has_data = False + for app in app_lst: + self.assertIsNotNone(app.application_id) + self.assertIsNotNone(app.secret) + + has_data = True + + self.assertTrue(has_data, "No applications") diff --git a/tests/syncio/test_httpx.py b/tests/syncio/test_httpx.py index 7e26ef8..fce43f5 100644 --- a/tests/syncio/test_httpx.py +++ b/tests/syncio/test_httpx.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -38,13 +37,13 @@ def test_http_get( latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") client_mock.assert_called() client_mock.__enter__.assert_called_once() client_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -64,7 +63,7 @@ def test_http_post( latch.host = "http://foo.bar.com" - latch.pair( + latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -72,7 +71,7 @@ def test_http_post( client_mock.__enter__.assert_called_once() client_mock.request.assert_called_once_with( "POST", - "http://foo.bar.com/api/1.0/pair/pinnnn", + "http://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -93,13 +92,13 @@ def test_https_get( latch.host = "https://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") client_mock.assert_called() client_mock.__enter__.assert_called_once() client_mock.request.assert_called_once_with( "GET", - "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + "https://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -119,7 +118,7 @@ def test_https_post( latch.host = "https://foo.bar.com" - latch.pair( + latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -127,7 +126,7 @@ def test_https_post( client_mock.__enter__.assert_called_once() client_mock.request.assert_called_once_with( "POST", - "https://foo.bar.com/api/1.0/pair/pinnnn", + "https://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -151,13 +150,13 @@ def test_proxy_http_get( client_mock.assert_called_with(proxy="http://proxy.bar.com:8443") latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") client_mock.assert_called() client_mock.__enter__.assert_called_once() client_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -180,13 +179,13 @@ def test_proxy_no_port_http_get( client_mock.assert_called_with(proxy="http://proxy.bar.com") latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") client_mock.assert_called() client_mock.__enter__.assert_called_once() client_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, diff --git a/tests/syncio/test_pure.py b/tests/syncio/test_pure.py index 7b1ff14..552dc20 100644 --- a/tests/syncio/test_pure.py +++ b/tests/syncio/test_pure.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -40,12 +39,12 @@ def test_http_get( latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") http_conn_mock.assert_called_once_with(latch.host, latch.port) http_conn_mock.request.assert_called_once_with( "GET", - "/api/1.0/status/account_id_3454657656454", + "/api/3.0/status/account_id_3454657656454", headers={ "Authorization": ANY, "X-11Paths-Date": ANY, @@ -68,14 +67,14 @@ def test_http_post( latch.host = "http://foo.bar.com" - latch.pair( + latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) http_conn_mock.assert_called_once_with(latch.host, latch.port) http_conn_mock.request.assert_called_once_with( "POST", - "/api/1.0/pair/pinnnn", + "/api/3.0/pair/pinnnn", "wallet=0x354tryhnghgr3&signature=0xwerghfgnfegwf", headers={ "Authorization": ANY, @@ -100,12 +99,12 @@ def test_https_get( latch.host = "https://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") https_conn_mock.assert_called_once_with(latch.host, latch.port) https_conn_mock.request.assert_called_once_with( "GET", - "/api/1.0/status/account_id_3454657656454", + "/api/3.0/status/account_id_3454657656454", headers={ "Authorization": ANY, "X-11Paths-Date": ANY, @@ -128,14 +127,14 @@ def test_https_post( latch.host = "https://foo.bar.com" - latch.pair( + latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) https_conn_mock.assert_called_once_with(latch.host, latch.port) https_conn_mock.request.assert_called_once_with( "POST", - "/api/1.0/pair/pinnnn", + "/api/3.0/pair/pinnnn", "wallet=0x354tryhnghgr3&signature=0xwerghfgnfegwf", headers={ "Authorization": ANY, @@ -161,12 +160,12 @@ def test_proxy_http_get( latch.proxy_host = "proxy.bar.com:8443" latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") https_conn_mock.assert_called_once_with(latch.proxy_host, latch.proxy_port) https_conn_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", headers={ "Authorization": ANY, "X-11Paths-Date": ANY, @@ -190,12 +189,12 @@ def test_proxy_http_port_get( latch.proxy_host = "proxy.bar.com:8443" latch.host = "http://foo.bar.com:8080" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") https_conn_mock.assert_called_once_with(latch.proxy_host, latch.proxy_port) https_conn_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com:8080/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com:8080/api/3.0/status/account_id_3454657656454", headers={ "Authorization": ANY, "X-11Paths-Date": ANY, @@ -219,13 +218,13 @@ def test_proxy_https_get( latch.proxy_host = "proxy.bar.com:8443" latch.host = "https://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") https_conn_mock.assert_called_once_with(latch.proxy_host, latch.proxy_port) https_conn_mock.set_tunnel.assert_called_once_with(latch.host, latch.port) https_conn_mock.request.assert_called_once_with( "GET", - "/api/1.0/status/account_id_3454657656454", + "/api/3.0/status/account_id_3454657656454", headers={ "Authorization": ANY, "X-11Paths-Date": ANY, diff --git a/tests/syncio/test_requests.py b/tests/syncio/test_requests.py index 43f1081..c6bbaf6 100644 --- a/tests/syncio/test_requests.py +++ b/tests/syncio/test_requests.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -38,13 +37,13 @@ def test_http_get( latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") session_mock.assert_called() session_mock.__enter__.assert_called_once() session_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -64,7 +63,7 @@ def test_http_post( latch.host = "http://foo.bar.com" - latch.pair( + latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -72,7 +71,7 @@ def test_http_post( session_mock.__enter__.assert_called_once() session_mock.request.assert_called_once_with( "POST", - "http://foo.bar.com/api/1.0/pair/pinnnn", + "http://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -93,13 +92,13 @@ def test_https_get( latch.host = "https://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") session_mock.assert_called() session_mock.__enter__.assert_called_once() session_mock.request.assert_called_once_with( "GET", - "https://foo.bar.com/api/1.0/status/account_id_3454657656454", + "https://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -119,7 +118,7 @@ def test_https_post( latch.host = "https://foo.bar.com" - latch.pair( + latch.account_pair( "pinnnn", web3_account="0x354tryhnghgr3", web3_signature="0xwerghfgnfegwf" ) @@ -127,7 +126,7 @@ def test_https_post( session_mock.__enter__.assert_called_once() session_mock.request.assert_called_once_with( "POST", - "https://foo.bar.com/api/1.0/pair/pinnnn", + "https://foo.bar.com/api/3.0/pair/pinnnn", data={"wallet": "0x354tryhnghgr3", "signature": "0xwerghfgnfegwf"}, headers={ "Authorization": ANY, @@ -156,13 +155,13 @@ def test_proxy_http_get( ) latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") session_mock.assert_called() session_mock.__enter__.assert_called_once() session_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, @@ -190,13 +189,13 @@ def test_proxy_no_port_http_get( ) latch.host = "http://foo.bar.com" - latch.status("account_id_3454657656454") + latch.account_status("account_id_3454657656454") session_mock.assert_called() session_mock.__enter__.assert_called_once() session_mock.request.assert_called_once_with( "GET", - "http://foo.bar.com/api/1.0/status/account_id_3454657656454", + "http://foo.bar.com/api/3.0/status/account_id_3454657656454", data=None, headers={ "Authorization": ANY, diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c9e45f5..1712f94 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 82cf4c4..26f37fc 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or @@ -34,6 +33,9 @@ TokenNotFound, ) from latch_sdk.models import ( + TOTP, + Application, + ApplicationCreateResponse, ExtraFeature, HistoryResponse, Instance, @@ -44,14 +46,17 @@ from latch_sdk.sansio import ( LatchSansIO, check_error, - response_add_instance, - response_history, + response_account_history, + response_account_pair, + response_account_status, + response_application_create, + response_application_list, + response_instance_create, response_instance_list, response_no_error, response_operation, response_operation_list, - response_pair, - response_status, + response_totp, ) from latch_sdk.utils import sign_data @@ -75,31 +80,33 @@ def test_with_error(self) -> None: self.assertEqual(ex.exception.message, "test message") -class ResponsePairTestCase(TestCase): +class ResponseAccountPairTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.pair("test_account")) + resp = Response(ResponseFactory.account_pair("test_account")) - account_id = response_pair(resp) + account_id = response_account_pair(resp) self.assertEqual(account_id, "test_account") def test_with_error(self) -> None: - resp = Response(ResponseFactory.pair_error_206_token_expired("test message")) + resp = Response( + ResponseFactory.account_pair_error_206_token_expired("test message") + ) with self.assertRaises(TokenNotFound) as ex: - response_pair(resp) + response_account_pair(resp) self.assertEqual(ex.exception.message, "test message") def test_with_error_already_paired(self) -> None: resp = Response( - ResponseFactory.pair_error_205_already_paired( + ResponseFactory.account_pair_error_205_already_paired( "test_account", "test message" ) ) with self.assertRaises(ApplicationAlreadyPaired) as ex: - response_pair(resp) + response_account_pair(resp) self.assertEqual(ex.exception.message, "test message") self.assertEqual(ex.exception.account_id, "test_account") @@ -120,20 +127,22 @@ def test_with_error(self) -> None: self.assertEqual(ex.exception.message, "test message") -class ResponseStatusTestCase(TestCase): +class ResponseAccountStatusTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.status_on("account_id")) + resp = Response(ResponseFactory.account_status_on("account_id")) - status = response_status(resp) + status = response_account_status(resp) self.assertIsInstance(status, Status) self.assertEqual(status.operation_id, "account_id") self.assertTrue(status.status) def test_require_otp(self) -> None: - resp = Response(ResponseFactory.status_on_two_factor("account_id", "123456")) + resp = Response( + ResponseFactory.account_status_on_two_factor("account_id", "123456") + ) - status = response_status(resp) + status = response_account_status(resp) self.assertIsInstance(status, Status) self.assertEqual(status.operation_id, "account_id") @@ -147,7 +156,7 @@ def test_with_error(self) -> None: ) with self.assertRaises(MaxActionsExceed) as ex: - response_status(resp) + response_account_status(resp) self.assertEqual(ex.exception.message, "test message") @@ -276,11 +285,11 @@ def test_with_error(self) -> None: self.assertEqual(ex.exception.message, "test message") -class ResponseAddInstanceTestCase(TestCase): +class ResponseInstanceCreateTestCase(TestCase): def test_no_error(self) -> None: resp = Response({"data": {"instance_id_1": "Instance Test 1"}}) - self.assertEqual(response_add_instance(resp), "instance_id_1") + self.assertEqual(response_instance_create(resp), "instance_id_1") def test_with_error(self) -> None: resp = Response( @@ -288,32 +297,72 @@ def test_with_error(self) -> None: ) with self.assertRaises(ApplicationNotFound) as ex: - response_add_instance(resp) + response_instance_create(resp) self.assertEqual(ex.exception.message, "test message") -class ResponseHistoryTestCase(TestCase): +class ResponseAccountHistoryTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.history()) + resp = Response(ResponseFactory.account_history()) - data = response_history(resp) + data = response_account_history(resp) self.assertIsInstance(data, HistoryResponse) -""" -TODO: Test history response +class ResponseTotpTestCase(TestCase): + def test_no_error(self) -> None: + resp = Response(ResponseFactory.totp_create_or_load()) + + data = response_totp(resp) + + self.assertIsInstance(data, TOTP) + self.assertIsNotNone(data.totp_id) + self.assertIsNotNone(data.app_id) + self.assertIsNotNone(data.issuer) + self.assertIsNotNone(data.secret) + self.assertIsNotNone(data.identity) + self.assertIsNotNone(data.identity.id) + self.assertIsNotNone(data.identity.name) + self.assertIsNotNone(data.qr) + self.assertIsNotNone(data.uri) + self.assertIsNotNone(data.disabled_by_subscription_limit) + + +class ResponseApplicationCreateTestCase(TestCase): + def test_no_error(self) -> None: + resp = Response(ResponseFactory.application_create()) + + data = response_application_create(resp) + + self.assertIsInstance(data, ApplicationCreateResponse) + self.assertIsNotNone(data.application_id) + self.assertIsNotNone(data.secret) -def response_history(resp: LatchResponse) -> HistoryResponse: - check_error(resp) - assert resp.data is not None, "No error or data" +class ResponseApplicationListTestCase(TestCase): + def test_no_error(self) -> None: + resp = Response(ResponseFactory.application_list()) + + data = response_application_list(resp) - return HistoryResponse.build_from_dict(resp.data) + has_data = False + for app in data: + self.assertIsInstance(app, Application) + self.assertIsNotNone(app.application_id) + self.assertIsNotNone(app.secret) + has_data = True + self.assertTrue(has_data, "No applications") -""" + def test_empty(self) -> None: + resp = Response(ResponseFactory.application_list_empty()) + + data = response_application_list(resp) + + for app in data: + self.fail("No empty list applications") class LatchClassMethodsTestCase(TestCase): @@ -330,6 +379,7 @@ def test_build_paths(self): "pair": "/api/v1/pair", "pair_with_id": "/api/v1/pairWithId", "subscription": "/api/v1/subscription", + "totp": "/api/v1/totps", "unlock": "/api/v1/unlock", "unpair": "/api/v1/unpair", }, @@ -347,6 +397,7 @@ def test_build_paths(self): "pair": "/api/p2/pair", "pair_with_id": "/api/p2/pairWithId", "subscription": "/api/p2/subscription", + "totp": "/api/p2/totps", "unlock": "/api/p2/unlock", "unpair": "/api/p2/unpair", }, @@ -396,12 +447,6 @@ def test_get_signature_from_header_out_of_bounds(self): with self.assertRaises(IndexError): LatchSansIO.get_signature_from_header("part0 part1") - def test_get_current_UTC(self): - self.assertEqual( - LatchSansIO.get_current_UTC(), - datetime.now(tz=timezone.utc).strftime(LatchSansIO.UTC_STRING_FORMAT), - ) - def test_get_serialized_headers_empty(self): self.assertEqual("", LatchSansIO.get_serialized_headers({})) @@ -467,12 +512,12 @@ def test_build_data_to_sign(self): "POST", "2025-02-24 10:32:23", "/path/to/op?query_param_1=1", - { + headers={ f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-2": "2", f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-3": "3", f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-1": "1", }, - { + params={ "param_3": "3", "param_1": "1", "param_2": "2", @@ -495,8 +540,7 @@ def test_build_data_to_sign_no_headers(self): "POST", "2025-02-24 10:32:23", "/path/to/op?query_param_1=1", - None, - { + params={ "param_3": "3", "param_1": "1", "param_2": "2", @@ -519,7 +563,7 @@ def test_build_data_to_sign_no_params(self): "GET", "2025-02-24 10:32:23", "/path/to/op?query_param_1=1", - { + headers={ f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-2": "2", f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-3": "3", f"{LatchSansIO.X_11PATHS_HEADER_PREFIX}valid-header-1": "1", @@ -608,18 +652,18 @@ def assert_request( method, headers[LatchTesting.DATE_HEADER_NAME], path, - { + headers={ k: v for k, v in headers.items() if k.startswith(LatchTesting.X_11PATHS_HEADER_PREFIX) and k != LatchTesting.DATE_HEADER_NAME }, - params, + params=params, ).encode(), ).decode(), ) - def test_pair_with_id(self) -> None: + def test_account_pair_with_id(self) -> None: account_id = "eregerdscvrtrd" def _http_cb( @@ -630,7 +674,7 @@ def _http_cb( ) -> Response: self.assertEqual(method, "GET") - self.assertEqual(path, "/api/1.0/pairWithId/eregerdscvrtrd") + self.assertEqual(path, "/api/3.0/pairWithId/eregerdscvrtrd") self.assert_request(method, path, headers, params) self.assertIsNone(params) @@ -641,7 +685,7 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.pair_with_id(account_id) + resp = latch.account_pair_with_id(account_id) self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) @@ -649,7 +693,7 @@ def _http_cb( http_cb.assert_called_once() - def test_pair_with_id_web3(self) -> None: + def test_account_pair_with_id_web3(self) -> None: account_id = "eregerdscvrtrd" web3_account = f"0x{secrets.token_hex(20)}" web3_signature = f"0x{secrets.token_hex(20)}" @@ -662,7 +706,7 @@ def _http_cb( ) -> Response: self.assertEqual(method, "POST") - self.assertEqual(path, "/api/1.0/pairWithId/eregerdscvrtrd") + self.assertEqual(path, "/api/3.0/pairWithId/eregerdscvrtrd") self.assert_request(method, path, headers, params) self.assertEqual( @@ -679,7 +723,7 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.pair_with_id( + resp = latch.account_pair_with_id( account_id, web3_account=web3_account, web3_signature=web3_signature ) @@ -690,7 +734,7 @@ def _http_cb( http_cb.assert_called_once() - def test_pair(self) -> None: + def test_account_pair(self) -> None: pin = "3edcvb" def _http_cb( @@ -701,7 +745,7 @@ def _http_cb( ) -> Response: self.assertEqual(method, "GET") - self.assertEqual(path, f"/api/1.0/pair/{pin}") + self.assertEqual(path, f"/api/3.0/pair/{pin}") self.assert_request(method, path, headers, params) self.assertIsNone(params) @@ -712,7 +756,7 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.pair(pin) + resp = latch.account_pair(pin) self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) @@ -720,7 +764,7 @@ def _http_cb( http_cb.assert_called_once() - def test_pair_already_paired(self) -> None: + def test_account_pair_already_paired(self) -> None: pin = "3edcvb" def _http_cb( @@ -731,20 +775,22 @@ def _http_cb( ) -> Response: self.assertEqual(method, "GET") - self.assertEqual(path, f"/api/1.0/pair/{pin}") + self.assertEqual(path, f"/api/3.0/pair/{pin}") self.assert_request(method, path, headers, params) self.assertIsNone(params) return Response( - ResponseFactory.pair_error_205_already_paired("latch_account_id") + ResponseFactory.account_pair_error_205_already_paired( + "latch_account_id" + ) ) http_cb = Mock(side_effect=_http_cb) latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.pair(pin) + resp = latch.account_pair(pin) self.assertIsInstance(resp, Response) self.assertIsNotNone(resp.data) @@ -755,7 +801,7 @@ def _http_cb( http_cb.assert_called_once() - def test_pair_web3(self) -> None: + def test_account_pair_web3(self) -> None: pin = "3edcvb" web3_account = f"0x{secrets.token_hex(20)}" web3_signature = f"0x{secrets.token_hex(20)}" @@ -768,7 +814,7 @@ def _http_cb( ) -> Response: self.assertEqual(method, "POST") - self.assertEqual(path, f"/api/1.0/pair/{pin}") + self.assertEqual(path, f"/api/3.0/pair/{pin}") self.assert_request(method, path, headers, params) self.assertEqual( @@ -785,7 +831,9 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.pair(pin, web3_account=web3_account, web3_signature=web3_signature) + resp = latch.account_pair( + pin, web3_account=web3_account, web3_signature=web3_signature + ) self.assertIsInstance(resp, Response) @@ -794,7 +842,7 @@ def _http_cb( http_cb.assert_called_once() - def assert_status_request( + def assert_account_status_request( self, method: str, path: str, @@ -807,7 +855,7 @@ def assert_status_request( self.assertIsNone(params) - def assert_status_response( + def assert_account_status_response( self, resp: Response, *, account_id: str, status: str = "on" ): self.assertIsInstance(resp, Response) @@ -934,7 +982,7 @@ def assert_status_response( ), ], ) # type: ignore - def test_status( + def test_account_status( self, operation_id: str | None, instance_id: str | None, @@ -950,11 +998,11 @@ def _http_cb( headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, ) -> Response: - self.assert_status_request(method, path, headers, params) + self.assert_account_status_request(method, path, headers, params) self.assertEqual( path, Template( - "/api/1.0/status/${account_id}" + expected_path + "/api/3.0/status/${account_id}" + expected_path ).safe_substitute( { "account_id": account_id, @@ -970,18 +1018,18 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.status( + resp = latch.account_status( account_id, operation_id=operation_id, instance_id=instance_id, nootp=nootp, silent=silent, ) - self.assert_status_response(resp, account_id=account_id, status="on") + self.assert_account_status_response(resp, account_id=account_id, status="on") http_cb.assert_called_once() - def test_unpair(self) -> None: + def test_account_unpair(self) -> None: account_id = "eregerdscvrtrd" def _http_cb( @@ -992,7 +1040,7 @@ def _http_cb( ) -> Response: self.assertEqual(method, "GET") - self.assertEqual(path, f"/api/1.0/unpair/{account_id}") + self.assertEqual(path, f"/api/3.0/unpair/{account_id}") self.assert_request(method, path, headers, params) self.assertIsNone(params) @@ -1003,7 +1051,7 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.unpair(account_id) + resp = latch.account_unpair(account_id) self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -1033,7 +1081,7 @@ def _http_cb( ), ], ) # type: ignore - def test_lock( + def test_account_lock( self, operation_id: str | None, instance_id: str | None, expected_path: str ) -> None: account_id = "eregerdscvrtrd" @@ -1048,7 +1096,7 @@ def _http_cb( self.assertEqual( path, - Template("/api/1.0/lock/${account_id}" + expected_path).safe_substitute( + Template("/api/3.0/lock/${account_id}" + expected_path).safe_substitute( { "account_id": account_id, "operation_id": operation_id, @@ -1066,7 +1114,7 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.lock( + resp = latch.account_lock( account_id, operation_id=operation_id, instance_id=instance_id ) self.assertIsInstance(resp, Response) @@ -1098,7 +1146,7 @@ def _http_cb( ), ], ) # type: ignore - def test_unlock( + def test_account_unlock( self, operation_id: str | None, instance_id: str | None, expected_path: str ) -> None: account_id = "eregerdscvrtrd" @@ -1114,7 +1162,7 @@ def _http_cb( self.assertEqual( path, Template( - "/api/1.0/unlock/${account_id}" + expected_path + "/api/3.0/unlock/${account_id}" + expected_path ).safe_substitute( { "account_id": account_id, @@ -1133,14 +1181,14 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.unlock( + resp = latch.account_unlock( account_id, operation_id=operation_id, instance_id=instance_id ) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_unlock_operation(self) -> None: + def test_account_unlock_operation(self) -> None: account_id = "eregerdscvrtrd" operation_id = "terthbdvcs4g5hxt" @@ -1152,7 +1200,7 @@ def _http_cb( ) -> Response: self.assertEqual(method, "POST") - self.assertEqual(path, f"/api/1.0/unlock/{account_id}/op/{operation_id}") + self.assertEqual(path, f"/api/3.0/unlock/{account_id}/op/{operation_id}") self.assert_request(method, path, headers, params) self.assertIsNone(params) @@ -1163,12 +1211,12 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.unlock(account_id, operation_id=operation_id) + resp = latch.account_unlock(account_id, operation_id=operation_id) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_history(self) -> None: + def test_account_history(self) -> None: account_id = "eregerdscvrtrd" to_dt = datetime.now(tz=timezone.utc) from_dt = to_dt - timedelta(days=2) @@ -1183,7 +1231,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}/{round(to_dt.timestamp() * 1000)}", + f"/api/3.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}/{round(to_dt.timestamp() * 1000)}", ) self.assert_request(method, path, headers, params) @@ -1195,12 +1243,12 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.history(account_id, from_dt=from_dt, to_dt=to_dt) + resp = latch.account_history(account_id, from_dt=from_dt, to_dt=to_dt) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_history_no_from(self) -> None: + def test_account_history_no_from(self) -> None: account_id = "eregerdscvrtrd" to_dt = datetime.now(tz=timezone.utc) @@ -1214,7 +1262,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/history/{account_id}/0/{round(to_dt.timestamp() * 1000)}", + f"/api/3.0/history/{account_id}/0/{round(to_dt.timestamp() * 1000)}", ) self.assert_request(method, path, headers, params) @@ -1226,13 +1274,13 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.history(account_id, to_dt=to_dt) + resp = latch.account_history(account_id, to_dt=to_dt) self.assertIsInstance(resp, Response) http_cb.assert_called_once() @patch("latch_sdk.sansio.datetime") - def test_history_no_to(self, datetime_cls_mock: Mock) -> None: + def test_account_history_no_to(self, datetime_cls_mock: Mock) -> None: current_dt: datetime = datetime.now(tz=timezone.utc) datetime_cls_mock.now.return_value = current_dt @@ -1253,7 +1301,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}" + f"/api/3.0/history/{account_id}/{round(from_dt.timestamp() * 1000)}" f"/{round(round(current_dt.timestamp() * 1000))}", ) self.assert_request(method, path, headers, params) @@ -1266,13 +1314,13 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.history(account_id, from_dt=from_dt) + resp = latch.account_history(account_id, from_dt=from_dt) self.assertIsInstance(resp, Response) http_cb.assert_called_once() @patch("latch_sdk.sansio.datetime") - def test_history_no_from_no_to(self, datetime_cls_mock: Mock) -> None: + def test_account_history_no_from_no_to(self, datetime_cls_mock: Mock) -> None: current_dt: datetime = datetime.now(tz=timezone.utc) datetime_cls_mock.now.return_value = current_dt @@ -1292,7 +1340,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/history/{account_id}/0/{round(current_dt.timestamp() * 1000)}", + f"/api/3.0/history/{account_id}/0/{round(current_dt.timestamp() * 1000)}", ) self.assert_request(method, path, headers, params) @@ -1304,12 +1352,12 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.history(account_id) + resp = latch.account_history(account_id) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_create_operation(self) -> None: + def test_operation_create(self) -> None: parent_id = "eregerdscvrtrd" def _http_cb( @@ -1322,7 +1370,7 @@ def _http_cb( self.assertEqual( path, - "/api/1.0/operation", + "/api/3.0/operation", ) self.assert_request(method, path, headers, params) @@ -1360,7 +1408,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/operation/{operation_id}", + f"/api/3.0/operation/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1407,7 +1455,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/operation/{operation_id}", + f"/api/3.0/operation/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1442,7 +1490,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/operation/{operation_id}", + f"/api/3.0/operation/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1477,7 +1525,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/operation/{operation_id}", + f"/api/3.0/operation/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1506,7 +1554,7 @@ def _http_cb( http_cb.assert_called_once() - def test_delete_operation(self) -> None: + def test_operation_remove(self) -> None: operation_id = "eregerdscvrtrd" def _http_cb( @@ -1519,7 +1567,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/operation/{operation_id}", + f"/api/3.0/operation/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1540,7 +1588,7 @@ def _http_cb( http_cb.assert_called_once() - def test_get_operations(self) -> None: + def test_operation_list(self) -> None: def _http_cb( method: str, path: str, @@ -1551,7 +1599,7 @@ def _http_cb( self.assertEqual( path, - "/api/1.0/operation", + "/api/3.0/operation", ) self.assert_request(method, path, headers, params) @@ -1570,7 +1618,7 @@ def _http_cb( http_cb.assert_called_once() - def test_get_operations_children(self) -> None: + def test_operation_list_children(self) -> None: operation_id = "eregerdscvrtrd" def _http_cb( @@ -1583,7 +1631,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/operation/{operation_id}", + f"/api/3.0/operation/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1604,7 +1652,7 @@ def _http_cb( http_cb.assert_called_once() - def test_get_instances(self) -> None: + def test_instance_list(self) -> None: account_id = "ryhtggfdwdffhgrd" def _http_cb( @@ -1617,7 +1665,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}", + f"/api/3.0/instance/{account_id}", ) self.assert_request(method, path, headers, params) @@ -1636,7 +1684,7 @@ def _http_cb( http_cb.assert_called_once() - def test_get_instances_operation(self) -> None: + def test_instance_list_operation(self) -> None: account_id = "ryhtggfdwdffhgrd" operation_id = "eregerdscvrtrd" @@ -1650,7 +1698,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/op/{operation_id}", + f"/api/3.0/instance/{account_id}/op/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1672,7 +1720,7 @@ def _http_cb( http_cb.assert_called_once() - def test_create_instance_operation(self) -> None: + def test_instance_create_operation(self) -> None: account_id = "ryhtggfdwdffhgrd" operation_id = "eregerdscvrtrd" @@ -1686,7 +1734,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/op/{operation_id}", + f"/api/3.0/instance/{account_id}/op/{operation_id}", ) self.assert_request(method, path, headers, params) @@ -1699,15 +1747,15 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.instance_create( - "new_instance", account_id, + "new_instance", operation_id=operation_id, ) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_create_instance(self) -> None: + def test_instance_create(self) -> None: account_id = "ryhtggfdwdffhgrd" def _http_cb( @@ -1720,7 +1768,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}", + f"/api/3.0/instance/{account_id}", ) self.assert_request(method, path, headers, params) @@ -1733,14 +1781,14 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.instance_create( - "new_instance", account_id, + "new_instance", ) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_update_instance(self) -> None: + def test_instance_update(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" @@ -1754,7 +1802,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/i/{instance_id}", + f"/api/3.0/instance/{account_id}/i/{instance_id}", ) self.assert_request(method, path, headers, params) @@ -1771,12 +1819,12 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.instance_update(instance_id, account_id, name="new_op") + resp = latch.instance_update(account_id, instance_id, name="new_op") self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_update_instance_two_factor(self) -> None: + def test_instance_update_two_factor(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" @@ -1790,7 +1838,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/i/{instance_id}", + f"/api/3.0/instance/{account_id}/i/{instance_id}", ) self.assert_request(method, path, headers, params) @@ -1806,13 +1854,13 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.instance_update( - instance_id, account_id, name="new_op", two_factor=ExtraFeature.MANDATORY + account_id, instance_id, name="new_op", two_factor=ExtraFeature.MANDATORY ) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_update_instance_lock_on_request(self) -> None: + def test_instance_update_lock_on_request(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" @@ -1826,7 +1874,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/i/{instance_id}", + f"/api/3.0/instance/{account_id}/i/{instance_id}", ) self.assert_request(method, path, headers, params) @@ -1842,13 +1890,13 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.instance_update( - instance_id, account_id, name="new_op", lock_on_request=ExtraFeature.OPT_IN + account_id, instance_id, name="new_op", lock_on_request=ExtraFeature.OPT_IN ) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_update_instance_two_factor_and_lock_on_request(self) -> None: + def test_instance_update_two_factor_and_lock_on_request(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" @@ -1862,7 +1910,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/i/{instance_id}", + f"/api/3.0/instance/{account_id}/i/{instance_id}", ) self.assert_request(method, path, headers, params) @@ -1882,8 +1930,8 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.instance_update( - instance_id, account_id, + instance_id, name="new_op", two_factor=ExtraFeature.MANDATORY, lock_on_request=ExtraFeature.OPT_IN, @@ -1892,7 +1940,7 @@ def _http_cb( http_cb.assert_called_once() - def test_update_instance_operation(self) -> None: + def test_instance_operation_update(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" operation_id = "87uyjhgfe4rtg" @@ -1907,7 +1955,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/op/{operation_id}/i/{instance_id}", + f"/api/3.0/instance/{account_id}/op/{operation_id}/i/{instance_id}", ) self.assert_request(method, path, headers, params) @@ -1925,13 +1973,13 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) resp = latch.instance_update( - instance_id, account_id, operation_id=operation_id, name="new_op" + account_id, instance_id, operation_id=operation_id, name="new_op" ) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_update_instance_fail(self) -> None: + def test_instance_update_fail(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" @@ -1948,11 +1996,11 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) with self.assertRaises(ValueError): - latch.instance_update(instance_id, account_id) + latch.instance_update(account_id, instance_id) http_cb.assert_not_called() - def test_delete_instance(self) -> None: + def test_instance_remove(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" @@ -1966,7 +2014,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/i/{instance_id}", + f"/api/3.0/instance/{account_id}/i/{instance_id}", ) self.assert_request(method, path, headers, params) @@ -1980,12 +2028,12 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.instance_remove(instance_id, account_id) + resp = latch.instance_remove(account_id, instance_id) self.assertIsInstance(resp, Response) http_cb.assert_called_once() - def test_delete_instance_operation(self) -> None: + def test_instance_remove_operation(self) -> None: account_id = "3463453etgvd" instance_id = "eregerdscvrtrd" operation_id = "87uyjhgfe4rtg" @@ -2000,7 +2048,7 @@ def _http_cb( self.assertEqual( path, - f"/api/1.0/instance/{account_id}/op/{operation_id}/i/{instance_id}", + f"/api/3.0/instance/{account_id}/op/{operation_id}/i/{instance_id}", ) self.assert_request(method, path, headers, params) @@ -2014,7 +2062,346 @@ def _http_cb( latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) - resp = latch.instance_remove(instance_id, account_id, operation_id=operation_id) + resp = latch.instance_remove(account_id, instance_id, operation_id=operation_id) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_totp_create(self) -> None: + user_id = "ryhtggfdwdffhgrd" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + "/api/3.0/totps", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + params, {"commonName": "new_totp", "userId": "ryhtggfdwdffhgrd"} + ) + + return Response(ResponseFactory.totp_create_or_load()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.totp_create( + user_id, + "new_totp", + ) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_totp_load(self) -> None: + totp_id = "3254y5ehtrgnfbdvwfgetrb" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/3.0/totps/{totp_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return Response(ResponseFactory.totp_create_or_load()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.totp_load( + totp_id, + ) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_totp_validate(self) -> None: + totp_id = "3254y5ehtrgnfbdvwfgetrb" + code = "324655" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/3.0/totps/{totp_id}/validate", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual(params, {"code": code}) + + return Response(ResponseFactory.totp_validate_error()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.totp_validate(totp_id, code=code) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_totp_remove(self) -> None: + totp_id = "3254y5ehtrgnfbdvwfgetrb" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "DELETE") + + self.assertEqual( + path, + f"/api/3.0/totps/{totp_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return Response(ResponseFactory.totp_validate_error()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.totp_remove(totp_id) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_application_list(self) -> None: + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + "/api/3.0/application", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return Response(ResponseFactory.application_list()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.application_list() + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_application_create(self) -> None: + name = "test_app" + contact_mail = "example@tu.com" + contact_phone = "+34755555555" + two_factor = ExtraFeature.OPT_IN + lock_on_request = ExtraFeature.MANDATORY + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "PUT") + + self.assertEqual( + path, + "/api/3.0/application", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + params, + { + "name": name, + "contactMail": contact_mail, + "contactPhone": contact_phone, + "two_factor": two_factor.value, + "lock_on_request": lock_on_request.value, + }, + ) + + return Response(ResponseFactory.application_create()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.application_create( + name, + contact_mail=contact_mail, + contact_phone=contact_phone, + two_factor=two_factor, + lock_on_request=lock_on_request, + ) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_application_create_defaults(self) -> None: + name = "test_app" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "PUT") + + self.assertEqual( + path, + "/api/3.0/application", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + params, + { + "name": name, + "contactMail": "", + "contactPhone": "", + "two_factor": ExtraFeature.DISABLED.value, + "lock_on_request": ExtraFeature.DISABLED.value, + }, + ) + + return Response(ResponseFactory.application_create()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.application_create(name) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_application_update(self) -> None: + application_id = "4t35yu654354324654" + name = "test_app" + contact_mail = "example@tu.com" + contact_phone = "+34755555555" + two_factor = ExtraFeature.OPT_IN + lock_on_request = ExtraFeature.MANDATORY + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "POST") + + self.assertEqual( + path, + f"/api/3.0/application/{application_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertEqual( + params, + { + "name": name, + "contactMail": contact_mail, + "contactPhone": contact_phone, + "two_factor": two_factor.value, + "lock_on_request": lock_on_request.value, + }, + ) + + return Response(ResponseFactory.no_data()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.application_update( + application_id, + name=name, + contact_mail=contact_mail, + contact_phone=contact_phone, + two_factor=two_factor, + lock_on_request=lock_on_request, + ) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_application_update_fail_no_data(self) -> None: + application_id = "4t35yu654354324654" + + http_cb = Mock() + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + with self.assertRaises(ValueError): + latch.application_update( + application_id, + ) + + http_cb.assert_not_called() + + def test_application_remove(self) -> None: + application_id = "4t35yu654354324654" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "DELETE") + + self.assertEqual( + path, + f"/api/3.0/application/{application_id}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return Response(ResponseFactory.application_list()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.application_remove(application_id) self.assertIsInstance(resp, Response) http_cb.assert_called_once() @@ -2167,7 +2554,7 @@ def test_build_url(self) -> None: latch.host = "foo.bar.com" self.assertEqual( - latch.build_url("/test", "param1=1¶m2=2"), + latch.build_url("/test", query="param1=1¶m2=2"), "https://foo.bar.com/test?param1=1¶m2=2", ) @@ -2177,7 +2564,7 @@ def test_build_url_port(self) -> None: latch.host = "foo.bar.com:8443" self.assertEqual( - latch.build_url("/test", "param1=1¶m2=2"), + latch.build_url("/test", query="param1=1¶m2=2"), "https://foo.bar.com:8443/test?param1=1¶m2=2", ) @@ -2187,7 +2574,7 @@ def test_build_url_http(self) -> None: latch.host = "http://foo.bar.com" self.assertEqual( - latch.build_url("/test", "param1=1¶m2=2"), + latch.build_url("/test", query="param1=1¶m2=2"), "http://foo.bar.com/test?param1=1¶m2=2", ) @@ -2197,6 +2584,6 @@ def test_build_url_http_port(self) -> None: latch.host = "http://foo.bar.com:8080" self.assertEqual( - latch.build_url("/test", "param1=1¶m2=2"), + latch.build_url("/test", query="param1=1¶m2=2"), "http://foo.bar.com:8080/test?param1=1¶m2=2", ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8be3de9..16f57ae 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,3 @@ -# This library offers an API to use LatchAuth in a python environment. # Copyright (C) 2025 Telefonica Digital España S.L. # # This library is free software you can redistribute it and/or From 1698b6e2d705c61b16eb9f8c2597496311955218 Mon Sep 17 00:00:00 2001 From: Alfred Date: Fri, 28 Feb 2025 12:13:57 +0100 Subject: [PATCH 12/25] Fixes --- Python.mk | 2 +- poetry.lock | 3 ++- pyproject.toml | 2 +- src/latch_sdk/models.py | 2 +- src/latch_sdk/utils.py | 14 ++++++++++++-- tests/test_sansio.py | 8 ++++---- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Python.mk b/Python.mk index c3ba560..07cfbb2 100644 --- a/Python.mk +++ b/Python.mk @@ -36,7 +36,7 @@ python-help: # Code recipes requirements: - ${POETRY_EXECUTABLE} install --no-interaction --no-ansi --all-extras + ${POETRY_EXECUTABLE} install --no-interaction --no-ansi --all-extras --without=docs black: ${POETRY_RUN} ruff format . diff --git a/poetry.lock b/poetry.lock index f814571..c7371ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1592,6 +1592,7 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +markers = {main = "extra == \"aiohttp\" and python_version < \"3.11\" or extra == \"httpx\" and python_version < \"3.13\" or python_version < \"3.10\""} [[package]] name = "urllib3" @@ -1719,4 +1720,4 @@ requests = ["requests"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0.0" -content-hash = "c29ca678ba2c48738c703f0e8090c71359c0e0a03045bf5b6336bf768595ce79" +content-hash = "a0826f491c37edda96fb636f25470d2406dcd3ffa894f0589eb6bf7860d5e742" diff --git a/pyproject.toml b/pyproject.toml index 3af68c3..f692f78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", ] -dependencies = ["typing-extensions (>=4.12.2,<5.0.0)"] +dependencies = ['typing-extensions (>=4.12.2,<5.0.0) ; python_version < "3.10"'] [project.urls] "Homepage" = "https://github.com/Telefonica/latch-sdk-python" diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index bccf46e..e15a4eb 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -548,7 +548,7 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": algorithm=data["algorithm"], digits=data["digits"], period=data["period"], - created_at=datetime.fromisoformat(data["createdAt"]), + created_at=datetime.strptime(data["createdAt"], "%Y-%m-%dT%H:%M:%SZ"), disabled_by_subscription_limit=data["disabledBySubscriptionLimit"], qr=data["qr"], uri=data["uri"], diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index c408176..f8855f3 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -16,7 +16,15 @@ from functools import wraps -from typing import Any, Callable, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Callable, TypeVar + +try: + from typing import ParamSpec +except ImportError: # pragma: no cover + from typing_extensions import ParamSpec + +if TYPE_CHECKING: # pragma: no cover + from typing import Concatenate def sign_data( @@ -43,7 +51,9 @@ def sign_data( def wraps_and_replace_return( meth: "Callable[Concatenate[Any, P], Any]", return_type: T, -) -> Callable[[Callable[Concatenate[TSelf, P], T]], Callable[Concatenate[TSelf, P], T]]: +) -> ( + "Callable[[Callable[Concatenate[TSelf, P], T]], Callable[Concatenate[TSelf, P], T]]" +): """ Wraps a method and replace return type. """ diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 26f37fc..072dc1a 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -984,8 +984,8 @@ def assert_account_status_response( ) # type: ignore def test_account_status( self, - operation_id: str | None, - instance_id: str | None, + operation_id: "str | None", + instance_id: "str | None", nootp: bool, silent: bool, expected_path: str, @@ -1082,7 +1082,7 @@ def _http_cb( ], ) # type: ignore def test_account_lock( - self, operation_id: str | None, instance_id: str | None, expected_path: str + self, operation_id: "str | None", instance_id: "str | None", expected_path: str ) -> None: account_id = "eregerdscvrtrd" @@ -1147,7 +1147,7 @@ def _http_cb( ], ) # type: ignore def test_account_unlock( - self, operation_id: str | None, instance_id: str | None, expected_path: str + self, operation_id: "str | None", instance_id: "str | None", expected_path: str ) -> None: account_id = "eregerdscvrtrd" From 3855f88c5a3a8718a450deb94ec2f4f6806c7bd0 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 4 Mar 2025 15:26:47 +0100 Subject: [PATCH 13/25] Added set-metadata operation, set metadata on pairing and phone number and location verification on status operation --- src/latch_sdk/asyncio/base.py | 7 ++ src/latch_sdk/cli/account.py | 64 ++++++++-- src/latch_sdk/cli/instance.py | 6 +- src/latch_sdk/cli/operation.py | 6 +- src/latch_sdk/cli/types.py | 2 +- src/latch_sdk/exceptions.py | 49 +++++++- src/latch_sdk/models.py | 9 +- src/latch_sdk/sansio.py | 203 +++++++++++++++++++++++++------ src/latch_sdk/syncio/base.py | 7 ++ src/latch_sdk/utils.py | 3 + tests/factory.py | 10 ++ tests/test_sansio.py | 213 ++++++++++++++++++++++++++++++--- 12 files changed, 507 insertions(+), 72 deletions(-) diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index 91bfc3f..50eca7a 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -14,6 +14,9 @@ # License along with this library if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +Base classes for asynchronous environments. +""" from typing import TYPE_CHECKING, Any, Awaitable, Callable @@ -81,6 +84,10 @@ class LatchSDK(LatchSDKSansIO[Awaitable[Response]]): account_history = wrap_method(response_account_history, BaseLatch.account_history) + account_set_metadata = wrap_method( + response_no_error, BaseLatch.account_set_metadata + ) + operation_list = wrap_method(response_operation_list, BaseLatch.operation_list) operation_create = wrap_method(response_operation, BaseLatch.operation_create) operation_update = wrap_method(response_no_error, BaseLatch.operation_update) diff --git a/src/latch_sdk/cli/account.py b/src/latch_sdk/cli/account.py index e290f0b..30b60ff 100644 --- a/src/latch_sdk/cli/account.py +++ b/src/latch_sdk/cli/account.py @@ -43,18 +43,26 @@ def account(): "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" ) @click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") +@click.option("--common-name", "-n", type=str, required=False, help="Common name") +@click.option("--phone-number", "-p", type=str, required=False, help="Phone number") @pass_latch_sdk def pair( latch: LatchSDK, token: str, - web3_account: str | None = None, - web3_signature: str | None = None, + web3_account: "str | None" = None, + web3_signature: "str | None" = None, + common_name: "str | None" = None, + phone_number: "str | None" = None, ): """ Pair a new latch. """ result = latch.account_pair( - token, web3_account=web3_account, web3_signature=web3_signature + token, + web3_account=web3_account, + web3_signature=web3_signature, + common_name=common_name, + phone_number=phone_number, ) render_account(result) @@ -66,18 +74,26 @@ def pair( "--web3-account", "-a", type=str, required=False, help="Web3 account identifier" ) @click.option("--web3-signature", "-s", type=str, required=False, help="Web3 signature") +@click.option("--common-name", "-n", type=str, required=False, help="Common name") +@click.option("--phone-number", "-p", type=str, required=False, help="Phone number") @pass_latch_sdk def pair_with_id( latch: LatchSDK, account_id: str, - web3_account: str | None = None, - web3_signature: str | None = None, + web3_account: "str | None" = None, + web3_signature: "str | None" = None, + common_name: "str | None" = None, + phone_number: "str | None" = None, ): """ Pair with user id a new latch. """ result = latch.account_pair_with_id( - account_id, web3_account=web3_account, web3_signature=web3_signature + account_id, + web3_account=web3_account, + web3_signature=web3_signature, + common_name=common_name, + phone_number=phone_number, ) render_account(result) @@ -114,6 +130,10 @@ def unpair( required=False, help="Do not push notification", ) +@click.option("--otp-code", "-c", type=str, required=False, help="Set OTP code") +@click.option("--otp-message", "-m", type=str, required=False, help="Set OTP message") +@click.option("--phone-number", "-p", type=str, required=False, help="Phone number") +@click.option("--location", "-l", type=str, required=False, help="Location identifier") @pass_latch_sdk def status( latch: LatchSDK, @@ -122,6 +142,10 @@ def status( instance_id: "str | None", nootp: bool, silent: bool, + otp_code: "str | None", + otp_message: "str | None", + phone_number: "str | None", + location: "str | None", ): """ Get latch status @@ -132,11 +156,34 @@ def status( instance_id=instance_id, nootp=nootp, silent=silent, + otp_code=otp_code, + otp_message=otp_message, + phone_number=phone_number, + location=location, ) render_status(status) +@account.command("set-metadata") +@click.argument("ACCOUNT_ID", type=str, required=True) +@click.option("--common-name", "-n", type=str, required=False, help="Common name") +@click.option("--phone-number", "-p", type=str, required=False, help="Phone number") +@pass_latch_sdk +def set_metadata( + latch: LatchSDK, + account_id: str, + common_name: "str | None" = None, + phone_number: "str | None" = None, +): + """Update account metadata""" + latch.account_set_metadata( + account_id, common_name=common_name, phone_number=phone_number + ) + + click.secho("Latch metadata updated", bg="green") + + @account.command() @click.argument("ACCOUNT_ID", type=str, required=True) @click.option( @@ -197,7 +244,10 @@ def unlock( ) @pass_latch_sdk def history( - latch: LatchSDK, account_id: str, from_dt: datetime | None, to_dt: datetime | None + latch: LatchSDK, + account_id: str, + from_dt: "datetime | None", + to_dt: "datetime | None", ): """Show latch actions history""" history = latch.account_history(account_id, from_dt=from_dt, to_dt=to_dt) diff --git a/src/latch_sdk/cli/instance.py b/src/latch_sdk/cli/instance.py index bce3ad5..a1f89a6 100644 --- a/src/latch_sdk/cli/instance.py +++ b/src/latch_sdk/cli/instance.py @@ -87,9 +87,9 @@ def update( latch: LatchSDK, ctx: click.Context, instance_id: str, - name: str | None, - two_factor: ExtraFeature | None, - lock_on_request: ExtraFeature | None, + name: "str | None", + two_factor: "ExtraFeature | None", + lock_on_request: "ExtraFeature | None", ): """Update a given instance""" latch.instance_update( diff --git a/src/latch_sdk/cli/operation.py b/src/latch_sdk/cli/operation.py index e239908..99100ff 100644 --- a/src/latch_sdk/cli/operation.py +++ b/src/latch_sdk/cli/operation.py @@ -109,9 +109,9 @@ def create( def update( latch: LatchSDK, operation_id: str, - name: str | None, - two_factor: ExtraFeature | None, - lock_on_request: ExtraFeature | None, + name: "str | None", + two_factor: "ExtraFeature | None", + lock_on_request: "ExtraFeature | None", ): """Update a given operation""" latch.operation_update( diff --git a/src/latch_sdk/cli/types.py b/src/latch_sdk/cli/types.py index 0c76a2d..da2b009 100644 --- a/src/latch_sdk/cli/types.py +++ b/src/latch_sdk/cli/types.py @@ -15,6 +15,6 @@ def __init__(self, enum: type[Enum], case_sensitive: bool = True) -> None: self.enum = enum def convert( - self, value: Any, param: click.Parameter | None, ctx: click.Context | None + self, value: Any, param: "click.Parameter | None", ctx: "click.Context | None" ) -> Any: return self.enum(super().convert(value, param, ctx)) diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py index 5faed07..9847bb4 100644 --- a/src/latch_sdk/exceptions.py +++ b/src/latch_sdk/exceptions.py @@ -14,6 +14,11 @@ # License along with this library if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +Latch exceptions. +""" + +from .models import Status _errors: dict[int, type["BaseLatchException"]] = {} @@ -46,6 +51,18 @@ class LatchWarning(BaseLatchException): pass +class ParingWarning(LatchWarning): + account_id: "str | None" = None + + +class StatusWarning(LatchWarning): + status: "Status | None" = None + + +class OpenGatewayWarning(ParingWarning, StatusWarning): + pass + + class LatchError(BaseLatchException): pass @@ -78,11 +95,9 @@ class UnpairingError(LatchError): CODE = 204 -class ApplicationAlreadyPaired(LatchWarning): +class ApplicationAlreadyPaired(ParingWarning): CODE = 205 - account_id: "str | None" = None - class TokenNotFound(LatchError): CODE = 206 @@ -118,3 +133,31 @@ class InvalidTOTP(LatchError): class TOPTGeneratingError(LatchError): CODE = 307 + + +class AccountPairedButDisabled(ParingWarning): + CODE = 701 + + +class AccountDisabledBySubscription(StatusWarning): + CODE = 702 + + +class NoSilentNotificationBySubscriptionLimit(StatusWarning): + CODE = 704 + + +class AccountPairedPhoneNumberVerificationFailed(OpenGatewayWarning): + CODE = 1200 + + +class AccountPairedPhoneNumberLocationFailed(OpenGatewayWarning): + CODE = 1201 + + +class AccountPairedPhoneNumberUserConsentRequired(OpenGatewayWarning): + CODE = 1202 + + +class AccountPairedPhoneNumberUserConsentRejected(OpenGatewayWarning): + CODE = 1203 diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index e15a4eb..6d23779 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -14,6 +14,9 @@ # License along with this library if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +Transfer data models. +""" from dataclasses import dataclass from datetime import datetime, timezone @@ -51,7 +54,7 @@ class Status: #: Operation identifier. operation_id: str - #: True means the latch is closed and any action must be blocked. + #: False means the latch is closed and any action must be blocked. status: bool #: Two factor data if it is required. @@ -135,7 +138,7 @@ class Operation: #: State of `Lock on request` feature. lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED - #: True means the latch is closed and any action must be blocked. + #: False means the latch is close and any action must be blocked. status: "bool | None" = None @classmethod @@ -370,7 +373,7 @@ class ApplicationHistory: #: URL to application image. image_url: str - #: Application current state. + #: Application current state. False means the latch is closed and any action must be blocked. status: bool #: When it was paired. diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index 3796ccf..8d0a55e 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -14,6 +14,9 @@ # License along with this library if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +Base Sansio classes. They implement all logic with on input/output processes. +""" from abc import ABC, abstractmethod from datetime import datetime, timezone @@ -32,7 +35,12 @@ ) from urllib.parse import urlparse -from .exceptions import ApplicationAlreadyPaired, BaseLatchException +from .exceptions import ( + BaseLatchException, + OpenGatewayWarning, + ParingWarning, + StatusWarning, +) from .models import ( TOTP, Application, @@ -68,13 +76,14 @@ class Paths(TypedDict): - check_status: str - pair: str - pair_with_id: str - unpair: str - lock: str - unlock: str - history: str + account_status: str + account_pair: str + account_pair_with_id: str + account_unpair: str + account_lock: str + account_unlock: str + account_history: str + account_metadata: str operation: str subscription: str application: str @@ -90,6 +99,7 @@ class LatchSansIO(ABC, Generic[TResponse]): API_PATH_LOCK_PATTERN = "/api/${version}/lock" API_PATH_UNLOCK_PATTERN = "/api/${version}/unlock" API_PATH_HISTORY_PATTERN = "/api/${version}/history" + API_PATH_METADATA_PATTERN = "/api/${version}/aliasMetadata" API_PATH_OPERATION_PATTERN = "/api/${version}/operation" API_PATH_SUBSCRIPTION_PATTERN = "/api/${version}/subscription" API_PATH_APPLICATION_PATTERN = "/api/${version}/application" @@ -223,27 +233,30 @@ def proxy_port(self, value: "int | None"): @classmethod def build_paths(cls, api_version: str) -> Paths: return { - "check_status": Template(cls.API_PATH_CHECK_STATUS_PATTERN).safe_substitute( - {"version": api_version} - ), - "pair": Template(cls.API_PATH_PAIR_PATTERN).safe_substitute( - {"version": api_version} - ), - "pair_with_id": Template(cls.API_PATH_PAIR_WITH_ID_PATTERN).safe_substitute( + "account_status": Template( + cls.API_PATH_CHECK_STATUS_PATTERN + ).safe_substitute({"version": api_version}), + "account_pair": Template(cls.API_PATH_PAIR_PATTERN).safe_substitute( {"version": api_version} ), - "unpair": Template(cls.API_PATH_UNPAIR_PATTERN).safe_substitute( + "account_pair_with_id": Template( + cls.API_PATH_PAIR_WITH_ID_PATTERN + ).safe_substitute({"version": api_version}), + "account_unpair": Template(cls.API_PATH_UNPAIR_PATTERN).safe_substitute( {"version": api_version} ), - "lock": Template(cls.API_PATH_LOCK_PATTERN).safe_substitute( + "account_lock": Template(cls.API_PATH_LOCK_PATTERN).safe_substitute( {"version": api_version} ), - "unlock": Template(cls.API_PATH_UNLOCK_PATTERN).safe_substitute( + "account_unlock": Template(cls.API_PATH_UNLOCK_PATTERN).safe_substitute( {"version": api_version} ), - "history": Template(cls.API_PATH_HISTORY_PATTERN).safe_substitute( + "account_history": Template(cls.API_PATH_HISTORY_PATTERN).safe_substitute( {"version": api_version} ), + "account_metadata": Template( + cls.API_PATH_METADATA_PATTERN, + ).safe_substitute({"version": api_version}), "operation": Template(cls.API_PATH_OPERATION_PATTERN).safe_substitute( {"version": api_version} ), @@ -481,26 +494,63 @@ def _http( ) -> TResponse: # pragma: no cover raise NotImplementedError("Client http must be implemented") + def _prepare_account_pair_params( + self, + *, + web3_account: "str | None" = None, + web3_signature: "str | None" = None, + common_name: "str | None" = None, + phone_number: "str | None" = None, + ) -> dict[str, str]: + params: dict[str, str] = {} + + if web3_account is not None or web3_signature is not None: + params.update( + {"wallet": web3_account or "", "signature": web3_signature or ""} + ) + + if common_name: + params["commonName"] = common_name + + if phone_number: + params["phoneNumber"] = phone_number + + return params + def account_pair_with_id( self, account_id: str, *, web3_account: "str | None" = None, web3_signature: "str | None" = None, + common_name: "str | None" = None, + phone_number: "str | None" = None, ) -> TResponse: """ Pairs the origin provider with a user account (mail). + It could raise :class:`warning exceptions ` (Recoverable errors) + containing the account identifier on exception property `account_id`. + :param account_id: The email for the pairing account - only useful in staging :param web3_account: The Ethereum-based account address to pairing the app. :param web3_signature: A proof-of-ownership signature with the account address. + :param common_name: Name send by the service provider attached to this pairing. + Typically the identity or name of the user in the SP. + :param phone_number: Phone number associated with the user. """ - path = "/".join((self._paths["pair_with_id"], account_id)) + path = "/".join((self._paths["account_pair_with_id"], account_id)) + + params = self._prepare_account_pair_params( + web3_account=web3_account, + web3_signature=web3_signature, + common_name=common_name, + phone_number=phone_number, + ) - if web3_account is None or web3_signature is None: + if len(params) == 0: return self._prepare_http("GET", path) - params = {"wallet": web3_account, "signature": web3_signature} return self._prepare_http("POST", path, params=params) def account_pair( @@ -509,20 +559,34 @@ def account_pair( *, web3_account: "str | None" = None, web3_signature: "str | None" = None, + common_name: "str | None" = None, + phone_number: "str | None" = None, ) -> TResponse: """ Pairs the token provider with a user account. + It could raise :class:`warning exceptions ` (Recoverable errors) + containing the account identifier on exception property `account_id`. + :param token: The token for pairing the app, generated by the Latch mobile app. :param web3_account: The Ethereum-based account address to pairing the app. :param web3_signature: A proof-of-ownership signature with the account address. + :param common_name: Name send by the service provider attached to this pairing. + Typically the identity or name of the user in the SP. + :param phone_number: Phone number associated with the user. """ - path = "/".join((self._paths["pair"], token)) + path = "/".join((self._paths["account_pair"], token)) + + params = self._prepare_account_pair_params( + web3_account=web3_account, + web3_signature=web3_signature, + common_name=common_name, + phone_number=phone_number, + ) - if web3_account is None or web3_signature is None: + if len(params) == 0: return self._prepare_http("GET", path) - params = {"wallet": web3_account, "signature": web3_signature} return self._prepare_http("POST", path, params=params) def account_status( @@ -533,18 +597,29 @@ def account_status( operation_id: "str | None" = None, silent: bool = False, nootp: bool = False, + otp_code: "str | None" = None, + otp_message: "str | None" = None, + phone_number: "str | None" = None, + location: "str | None" = None, ) -> TResponse: """ Return operation status for a given accountId and operation while sending some custom data (Like OTP token or a message). + It could raise :class:`warning exceptions ` (Recoverable errors) + containing the account status object on `status` exception property. + :param account_id: The account identifier which status is going to be retrieved. :param instance_id: The instance identifier. :param operation_id: The operation identifier which status is going to be retrieved. :param silent: True for not sending lock/unlock push notifications to the mobile devices, false otherwise. :param nootp: True for not generating a OTP if needed. + :param otp_code: Set OTP code manually. + :param otp_message: Set OTP message. + :param phone_number: Phone number associated with the user. + :param location: Allowed location. """ - parts = [self._paths["check_status"], account_id] + parts = [self._paths["account_status"], account_id] if operation_id: parts.extend(("op", operation_id)) @@ -557,15 +632,62 @@ def account_status( if silent: parts.append("silent") + params = {} + if otp_code is not None: + params["otp"] = otp_code + + if otp_message is not None: + params["msg"] = otp_message + + if phone_number: + params["phoneNumber"] = phone_number + + if location: + params["location"] = location + + if len(params): + return self._prepare_http("POST", "/".join(parts), params=params) + return self._prepare_http("GET", "/".join(parts)) + def account_set_metadata( + self, + account_id: str, + *, + common_name: "str | None" = None, + phone_number: "str | None" = None, + ) -> TResponse: + """ + Set account metadata. + + :param common_name: Name send by the service provider attached to this pairing. + Typically the identity or name of the user in the SP. + :param phone_number: Phone number associated with the user. + """ + parts = [self._paths["account_metadata"], account_id] + + params = {} + + if common_name is not None: + params["commonName"] = common_name + + if phone_number is not None: + params["phoneNumber"] = phone_number + + if len(params) == 0: + raise ValueError("No metadata data") + + return self._prepare_http("POST", "/".join(parts), params=params) + def account_unpair(self, account_id: str) -> TResponse: """ Unpairs the origin provider with a user account. :param account_id: The account identifier. """ - return self._prepare_http("GET", "/".join((self._paths["unpair"], account_id))) + return self._prepare_http( + "GET", "/".join((self._paths["account_unpair"], account_id)) + ) def account_lock( self, @@ -581,7 +703,7 @@ def account_lock( :param instance_id: The instance identifier. :param operation_id: The operation identifier. """ - parts = [self._paths["lock"], account_id] + parts = [self._paths["account_lock"], account_id] if operation_id is not None: parts.extend(("op", operation_id)) @@ -605,7 +727,7 @@ def account_unlock( :param instance_id: The instance identifier :param operation_id: The operation identifier """ - parts = [self._paths["unlock"], account_id] + parts = [self._paths["account_unlock"], account_id] if operation_id is not None: parts.extend(("op", operation_id)) @@ -636,7 +758,7 @@ def account_history( "GET", "/".join( ( - self._paths["history"], + self._paths["account_history"], account_id, str(round(from_dt.timestamp() * 1000)), str(round(to_dt.timestamp() * 1000)), @@ -998,7 +1120,7 @@ def response_account_pair(resp: Response) -> str: """ try: check_error(resp) - except ApplicationAlreadyPaired as ex: + except (ParingWarning, OpenGatewayWarning) as ex: ex.account_id = cast(str, resp.data["accountId"]) # type: ignore raise ex @@ -1008,17 +1130,26 @@ def response_account_pair(resp: Response) -> str: return cast(str, resp.data["accountId"]) +def _build_account_status_from_response(resp: Response): + assert resp.data is not None, "No error or data" + + op_id, status_data = cast(dict[str, dict], resp.data["operations"]).popitem() + + return Status.build_from_dict(status_data, operation_id=op_id) + + def response_account_status(resp: Response) -> Status: """ Builds status object from response """ - check_error(resp) - - assert resp.data is not None, "No error or data" + try: + check_error(resp) + except (StatusWarning, OpenGatewayWarning) as ex: + ex.status = _build_account_status_from_response(resp) - op_id, status_data = cast(dict[str, dict], resp.data["operations"]).popitem() + raise ex - return Status.build_from_dict(status_data, operation_id=op_id) + return _build_account_status_from_response(resp) def response_account_history(resp: Response) -> HistoryResponse: diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index 5701e97..6a86816 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -14,6 +14,9 @@ # License along with this library if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +Base classes for asynchronous environments. +""" from typing import TYPE_CHECKING, Any, Callable @@ -81,6 +84,10 @@ class LatchSDK(LatchSDKSansIO[Response]): account_history = wrap_method(response_account_history, BaseLatch.account_history) + account_set_metadata = wrap_method( + response_no_error, BaseLatch.account_set_metadata + ) + operation_list = wrap_method(response_operation_list, BaseLatch.operation_list) operation_create = wrap_method(response_operation, BaseLatch.operation_create) operation_update = wrap_method(response_no_error, BaseLatch.operation_update) diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index f8855f3..aa81059 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -14,6 +14,9 @@ # License along with this library if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +""" +Global utilities. +""" from functools import wraps from typing import TYPE_CHECKING, Any, Callable, TypeVar diff --git a/tests/factory.py b/tests/factory.py index ce4e03a..30cea92 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -85,6 +85,16 @@ def account_status_off( ): return cls.account_status("off", operation_id) + @classmethod + def account_status_on_error_702_disabled_by_subscription( + cls, + operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ", + ): + return { + **cls.account_status_on(operation_id), + **cls.error(702, "Account disabled by subscription"), + } + @classmethod def account_history(cls): return { diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 072dc1a..28c6397 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -26,6 +26,7 @@ from parametrize import parametrize from latch_sdk.exceptions import ( + AccountDisabledBySubscription, ApplicationAlreadyPaired, ApplicationNotFound, InvalidCredentials, @@ -160,6 +161,16 @@ def test_with_error(self) -> None: self.assertEqual(ex.exception.message, "test message") + def test_with_error702_disabled_by_subscription(self) -> None: + resp = Response( + ResponseFactory.account_status_on_error_702_disabled_by_subscription() + ) + + with self.assertRaises(AccountDisabledBySubscription) as ex: + response_account_status(resp) + + self.assertIsNotNone(ex.exception.status) + class ResponseOperationTestCase(TestCase): def test_no_error_operation_key(self) -> None: @@ -371,17 +382,18 @@ def test_build_paths(self): LatchSansIO.build_paths("v1"), { "application": "/api/v1/application", - "check_status": "/api/v1/status", - "history": "/api/v1/history", + "account_status": "/api/v1/status", + "account_history": "/api/v1/history", "instance": "/api/v1/instance", - "lock": "/api/v1/lock", + "account_lock": "/api/v1/lock", "operation": "/api/v1/operation", - "pair": "/api/v1/pair", - "pair_with_id": "/api/v1/pairWithId", + "account_pair": "/api/v1/pair", + "account_pair_with_id": "/api/v1/pairWithId", "subscription": "/api/v1/subscription", "totp": "/api/v1/totps", - "unlock": "/api/v1/unlock", - "unpair": "/api/v1/unpair", + "account_unlock": "/api/v1/unlock", + "account_unpair": "/api/v1/unpair", + "account_metadata": "/api/v1/aliasMetadata", }, ) @@ -389,17 +401,18 @@ def test_build_paths(self): LatchSansIO.build_paths("p2"), { "application": "/api/p2/application", - "check_status": "/api/p2/status", - "history": "/api/p2/history", + "account_status": "/api/p2/status", + "account_history": "/api/p2/history", "instance": "/api/p2/instance", - "lock": "/api/p2/lock", + "account_lock": "/api/p2/lock", "operation": "/api/p2/operation", - "pair": "/api/p2/pair", - "pair_with_id": "/api/p2/pairWithId", + "account_pair": "/api/p2/pair", + "account_pair_with_id": "/api/p2/pairWithId", "subscription": "/api/p2/subscription", "totp": "/api/p2/totps", - "unlock": "/api/p2/unlock", - "unpair": "/api/p2/unpair", + "account_unlock": "/api/p2/unlock", + "account_unpair": "/api/p2/unpair", + "account_metadata": "/api/p2/aliasMetadata", }, ) @@ -693,6 +706,47 @@ def _http_cb( http_cb.assert_called_once() + def test_account_pair_with_id_metadata(self) -> None: + account_id = "eregerdscvrtrd" + common_name = "My common name" + phone_number = "+34655555555" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "POST") + + self.assertEqual(path, "/api/3.0/pairWithId/eregerdscvrtrd") + self.assert_request(method, path, headers, params) + + self.assertEqual( + params, + { + "commonName": common_name, + "phoneNumber": phone_number, + }, + ) + + return Response({"data": {"accountId": "latch_account_id"}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.account_pair_with_id( + account_id, common_name=common_name, phone_number=phone_number + ) + + self.assertIsInstance(resp, Response) + + self.assertIsNotNone(resp.data) + self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore + + http_cb.assert_called_once() + def test_account_pair_with_id_web3(self) -> None: account_id = "eregerdscvrtrd" web3_account = f"0x{secrets.token_hex(20)}" @@ -842,7 +896,7 @@ def _http_cb( http_cb.assert_called_once() - def assert_account_status_request( + def assert_request_get( self, method: str, path: str, @@ -855,6 +909,17 @@ def assert_account_status_request( self.assertIsNone(params) + def assert_request_post( + self, + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ): + self.assertEqual(method, "POST") + + self.assert_request(method, path, headers, params) + def assert_account_status_response( self, resp: Response, *, account_id: str, status: str = "on" ): @@ -998,7 +1063,7 @@ def _http_cb( headers: Mapping[str, str], params: "Mapping[str, str] | None" = None, ) -> Response: - self.assert_account_status_request(method, path, headers, params) + self.assert_request_get(method, path, headers, params) self.assertEqual( path, Template( @@ -1029,6 +1094,72 @@ def _http_cb( http_cb.assert_called_once() + def test_account_status_metadata( + self, + ) -> None: + account_id = "eregerdscvrtrd" + phone_number = "+34655555555" + location = "location_id" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assert_request_post(method, path, headers, params) + self.assertEqual(path, f"/api/3.0/status/{account_id}") + + self.assertEqual( + params, {"phoneNumber": phone_number, "location": location} + ) + return Response({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.account_status( + account_id, + location=location, + phone_number=phone_number, + ) + self.assert_account_status_response(resp, account_id=account_id, status="on") + + http_cb.assert_called_once() + + def test_account_status_otp( + self, + ) -> None: + account_id = "eregerdscvrtrd" + otp = "2343546" + msg = "Message OTP" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assert_request_post(method, path, headers, params) + self.assertEqual(path, f"/api/3.0/status/{account_id}") + + self.assertEqual(params, {"otp": otp, "msg": msg}) + return Response({"data": {account_id: {"status": "on"}}}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.account_status( + account_id, + otp_code=otp, + otp_message=msg, + ) + self.assert_account_status_response(resp, account_id=account_id, status="on") + + http_cb.assert_called_once() + def test_account_unpair(self) -> None: account_id = "eregerdscvrtrd" @@ -1357,6 +1488,56 @@ def _http_cb( http_cb.assert_called_once() + def test_account_set_metadata( + self, + ) -> None: + account_id = "eregerdscvrtrd" + phone_number = "+34655555555" + common_name = "My common name" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assert_request_post(method, path, headers, params) + self.assertEqual(path, f"/api/3.0/aliasMetadata/{account_id}") + + self.assertEqual( + params, {"phoneNumber": phone_number, "commonName": common_name} + ) + return Response({}) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.account_set_metadata( + account_id, + common_name=common_name, + phone_number=phone_number, + ) + + self.assertIsNone(resp.data) + self.assertIsNone(resp.error) + + http_cb.assert_called_once() + + def test_account_set_metadata_fail( + self, + ) -> None: + account_id = "eregerdscvrtrd" + + http_cb = Mock() + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + with self.assertRaises(ValueError): + latch.account_set_metadata(account_id) + + http_cb.assert_not_called() + def test_operation_create(self) -> None: parent_id = "eregerdscvrtrd" From ccc6df7e2804a9e897fb6a9f4cc626e5dedc06e1 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 4 Mar 2025 16:29:46 +0100 Subject: [PATCH 14/25] Better docs and fixes --- docs/source/api/exceptions.rst | 99 ++++++++++++++++++++++++++++++++++ docs/source/conf.py | 10 +++- src/latch_sdk/exceptions.py | 8 +-- src/latch_sdk/sansio.py | 4 +- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/docs/source/api/exceptions.rst b/docs/source/api/exceptions.rst index 9a54e47..c73b18b 100644 --- a/docs/source/api/exceptions.rst +++ b/docs/source/api/exceptions.rst @@ -7,3 +7,102 @@ Exceptions .. automodule:: latch_sdk.exceptions :members: + :exclude-members: PairingWarning,ApplicationAlreadyPaired,AccountPairedButDisabled,StatusWarning, + AccountDisabledBySubscription,NoSilentNotificationBySubscriptionLimit, + OpenGatewayWarning,AccountPairedPhoneNumberVerificationFailed, + AccountPairedPhoneNumberLocationFailed,AccountPairedPhoneNumberUserConsentRequired, + AccountPairedPhoneNumberUserConsentRejected + + ---------------- + Pairing warnings + ---------------- + + Pairing warnings are errors that occurs during pairing operations, but developer could recover execution + from them. It means it can continue execution like it never had happened. + + .. code-block:: python + + import logging + + from latch_sdk.syncio import LatchSDK + from latch_sdk.syncio.pure import Latch + + + latch = LatchSDK(Latch(MY_APP_ID, MY_APP_SECRET)) + + try: + account_id = latch.account_pair(MY_TOKEN) + except PairingWarning as ex: + logging.warning(ex) + + account_id = ex.account_id + + + .. autoexception:: PairingWarning + :members: + + .. autoexception:: ApplicationAlreadyPaired + :members: + + .. autoexception:: AccountPairedButDisabled + :members: + + + --------------- + Status warnings + --------------- + + Status warnings are errors that occurs during status operation, but developer could recover execution + from them. It means it can continue execution like it never had happened. + + .. code-block:: python + + import logging + + from latch_sdk.syncio import LatchSDK + from latch_sdk.syncio.pure import Latch + + + latch = LatchSDK(Latch(MY_APP_ID, MY_APP_SECRET)) + + try: + status = latch.account_status(MY_ACCOUNT_ID) + except StatusWarning as ex: + logging.warning(ex) + + status = ex.status + + .. autoexception:: StatusWarning + :members: + + .. autoexception:: AccountDisabledBySubscription + :members: + + .. autoexception:: NoSilentNotificationBySubscriptionLimit + :members: + + + -------------------- + OpenGateway warnings + -------------------- + + OpenGateway warnings are :class:`PairingWarning` and :class:`StatusWarning` on pairing and status operations respectively. + + .. autoexception:: OpenGatewayWarning + :members: + + .. autoexception:: AccountPairedPhoneNumberVerificationFailed + :members: + + .. autoexception:: AccountPairedPhoneNumberLocationFailed + :members: + + .. autoexception:: AccountPairedPhoneNumberUserConsentRequired + :members: + + .. autoexception:: AccountPairedPhoneNumberUserConsentRejected + :members: + + ------ + Errors + ------ \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index b07c0c7..9ff1303 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,7 +13,7 @@ copyright = f"{date.today().year}, Telefónica Innovación Digital" author = "Telefónica Innovación Digital" release = metadata.version("latch-sdk-telefonica") - +language = "en" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -59,6 +59,7 @@ "members": "", "member-order": "bysource", "undoc-members": True, + "show-inheritance": True, } @@ -77,3 +78,10 @@ github_username = "Telefonica" github_repository = "latch-sdk-telefonica" + + +# -- Latex ------------------------------ + +latex_elements = { + "maxlistdepth": "9", +} diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py index 9847bb4..5674f19 100644 --- a/src/latch_sdk/exceptions.py +++ b/src/latch_sdk/exceptions.py @@ -51,7 +51,7 @@ class LatchWarning(BaseLatchException): pass -class ParingWarning(LatchWarning): +class PairingWarning(LatchWarning): account_id: "str | None" = None @@ -59,7 +59,7 @@ class StatusWarning(LatchWarning): status: "Status | None" = None -class OpenGatewayWarning(ParingWarning, StatusWarning): +class OpenGatewayWarning(PairingWarning, StatusWarning): pass @@ -95,7 +95,7 @@ class UnpairingError(LatchError): CODE = 204 -class ApplicationAlreadyPaired(ParingWarning): +class ApplicationAlreadyPaired(PairingWarning): CODE = 205 @@ -135,7 +135,7 @@ class TOPTGeneratingError(LatchError): CODE = 307 -class AccountPairedButDisabled(ParingWarning): +class AccountPairedButDisabled(PairingWarning): CODE = 701 diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index 8d0a55e..02ead6b 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -38,7 +38,7 @@ from .exceptions import ( BaseLatchException, OpenGatewayWarning, - ParingWarning, + PairingWarning, StatusWarning, ) from .models import ( @@ -1120,7 +1120,7 @@ def response_account_pair(resp: Response) -> str: """ try: check_error(resp) - except (ParingWarning, OpenGatewayWarning) as ex: + except (PairingWarning, OpenGatewayWarning) as ex: ex.account_id = cast(str, resp.data["accountId"]) # type: ignore raise ex From 5ce918ec38236810dddbe99d208bee9bb4e176cf Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 4 Mar 2025 16:39:33 +0100 Subject: [PATCH 15/25] Fix docs --- docs/source/getting-started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index ae62e06..7f2c038 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -107,7 +107,7 @@ must used to instance the :class:`~latch_sdk.asyncio.LatchSDK`. import asyncio from latch_sdk.asyncio import LatchSDK - from latch_sdk.asyncio.pure import Latch + from latch_sdk.asyncio.aiohttp import Latch async main(): latch = LatchSDK(Latch(app_id=MY_APP_ID, secret=MY_SECRET)) From c4d841222e9d96e46846fcb8ea2816f2985ed630 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 09:34:03 +0100 Subject: [PATCH 16/25] Improve tests and docs --- src/latch_sdk/asyncio/aiohttp.py | 4 +- src/latch_sdk/asyncio/httpx.py | 2 +- src/latch_sdk/models.py | 100 +++++++++++++------ src/latch_sdk/sansio.py | 31 +++--- src/latch_sdk/syncio/httpx.py | 2 +- src/latch_sdk/syncio/pure.py | 4 +- src/latch_sdk/syncio/requests.py | 2 +- tests/asyncio/test_base.py | 32 +++--- tests/syncio/test_base.py | 32 +++--- tests/test_models.py | 37 +++++++ tests/test_sansio.py | 165 ++++++++++++++----------------- 11 files changed, 242 insertions(+), 169 deletions(-) create mode 100644 tests/test_models.py diff --git a/src/latch_sdk/asyncio/aiohttp.py b/src/latch_sdk/asyncio/aiohttp.py index 51c44f2..4f2b6d3 100644 --- a/src/latch_sdk/asyncio/aiohttp.py +++ b/src/latch_sdk/asyncio/aiohttp.py @@ -75,4 +75,6 @@ async def _http( method, self.build_url(path), data=params, headers=headers ) as response: response.raise_for_status() - return Response(await response.json() if await response.text() else {}) + return Response.build_from_dict( + await response.json() if await response.text() else {} + ) diff --git a/src/latch_sdk/asyncio/httpx.py b/src/latch_sdk/asyncio/httpx.py index c193782..922a8ee 100644 --- a/src/latch_sdk/asyncio/httpx.py +++ b/src/latch_sdk/asyncio/httpx.py @@ -80,4 +80,4 @@ async def _http( response.raise_for_status() - return Response(response.json() if response.text else {}) + return Response.build_from_dict(response.json() if response.text else {}) diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index 6d23779..5baaf6d 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -505,7 +505,14 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @dataclass(frozen=True) class Identity: + """ + User identity model. + """ + + #: User identifier. id: str + + #: User name name: str @classmethod @@ -521,17 +528,40 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @dataclass(frozen=True) class TOTP: + #: TOTP identifier totp_id: str + + #: Secret used to create one-time tokens. secret: str + + #: Dependent application. app_id: str + + #: User identity. identity: Identity + + #: Service provider. issuer: str + + #: Algorithm used to generate tokens. algorithm: str + + #: Number of digits of each token. digits: int + + #: Life time in second for each token. period: int + + #: When TOTP was created. created_at: datetime + + #: Whether TOTP must be disabled when subscription raises limit. disabled_by_subscription_limit: bool + + #: HTML encode QR image for :attr:`uri` field. qr: str + + #: URI representing whole TOTP data. uri: str @classmethod @@ -559,31 +589,37 @@ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": class ErrorData(TypedDict): + #: Error code code: int + + #: Error message message: str +@dataclass(frozen=True) class Error: - def __init__(self, data: ErrorData): - """ - Error model - """ + """ + Error model + """ - self.code = data["code"] - self.message = data["message"] + #: Error code + code: int - def to_dict(self) -> ErrorData: - return {"code": self.code, "message": self.message} + #: Error message + message: str - def __repr__(self) -> str: - import json + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ - return json.dumps(self.to_dict()) + data.update(kwargs) - def __str__(self) -> str: - return self.__repr__() + return cls(code=data["code"], message=data["message"]) +@dataclass(frozen=True) class Response: """ This class models a response from any of the endpoints in the Latch API. @@ -592,28 +628,32 @@ class Response: could have valid information in the data field and at the same time inform of an error. """ - def __init__(self, data: "dict[str, Any]"): + #: Response data + data: "dict[str, Any] | None" = None + + #: Response error data + error: "Error | None" = None + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": """ - :param data: a json string or a dict received from one of the methods of the Latch API + Builds model from dict """ - self.data: "dict[str, Any] | None" = None - if "data" in data: - self.data = data["data"] - self.error: "Error | None" = None - if "error" in data: - self.error = Error(data["error"]) + data.update(kwargs) - def to_dict(self): + return cls( + data=data.get("data"), + error=Error.build_from_dict(data["error"]) if "error" in data else None, + ) + + def raise_on_error(self) -> None: """ - :return: a dict with the data and error parts set if they exist + Raise an exception if response contains an error """ - result = {} - - if self.data: - result["data"] = self.data + from .exceptions import BaseLatchException - if self.error: - result["error"] = self.error.to_dict() + if not self.error: + return - return result + raise BaseLatchException(self.error.code, self.error.message) diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index 02ead6b..fa6652c 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -36,7 +36,6 @@ from urllib.parse import urlparse from .exceptions import ( - BaseLatchException, OpenGatewayWarning, PairingWarning, StatusWarning, @@ -1097,19 +1096,11 @@ def __init__(self, core: LatchSansIO[TResponse]): self._core: LatchSansIO[TResponse] = core -def check_error(resp: Response): - """Check whether response contains an error or not and raise an exception if it does""" - if not resp.error: - return - - raise BaseLatchException(resp.error.code, resp.error.message) - - def response_no_error(resp: Response) -> Literal[True]: """ Returns `True` if not error """ - check_error(resp) + resp.raise_on_error() return True @@ -1119,7 +1110,7 @@ def response_account_pair(resp: Response) -> str: Gets accountId from response """ try: - check_error(resp) + resp.raise_on_error() except (PairingWarning, OpenGatewayWarning) as ex: ex.account_id = cast(str, resp.data["accountId"]) # type: ignore @@ -1143,7 +1134,7 @@ def response_account_status(resp: Response) -> Status: Builds status object from response """ try: - check_error(resp) + resp.raise_on_error() except (StatusWarning, OpenGatewayWarning) as ex: ex.status = _build_account_status_from_response(resp) @@ -1156,7 +1147,7 @@ def response_account_history(resp: Response) -> HistoryResponse: """ Builds history response object from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" @@ -1167,7 +1158,7 @@ def response_operation(resp: Response) -> Operation: """ Builds operation object from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" @@ -1186,7 +1177,7 @@ def response_operation_list(resp: Response) -> Iterable[Operation]: """ Builds operation object list from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" @@ -1200,7 +1191,7 @@ def response_instance_list(resp: Response) -> Iterable[Instance]: """ Builds instance object list from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" @@ -1211,7 +1202,7 @@ def response_instance_create(resp: Response) -> str: """ Gets instance identifier from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" @@ -1222,7 +1213,7 @@ def response_totp(resp: Response) -> TOTP: """ Gets TOTP object from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" @@ -1233,7 +1224,7 @@ def response_application_create(resp: Response) -> ApplicationCreateResponse: """ Application creation information from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" @@ -1244,7 +1235,7 @@ def response_application_list(resp: Response) -> Iterable[Application]: """ Builds application object list from response """ - check_error(resp) + resp.raise_on_error() assert resp.data is not None, "No error or data" diff --git a/src/latch_sdk/syncio/httpx.py b/src/latch_sdk/syncio/httpx.py index f57a0c6..a1c6c04 100644 --- a/src/latch_sdk/syncio/httpx.py +++ b/src/latch_sdk/syncio/httpx.py @@ -80,4 +80,4 @@ def _http( response.raise_for_status() - return Response(response.json() if response.text else {}) + return Response.build_from_dict(response.json() if response.text else {}) diff --git a/src/latch_sdk/syncio/pure.py b/src/latch_sdk/syncio/pure.py index abb3190..289d590 100644 --- a/src/latch_sdk/syncio/pure.py +++ b/src/latch_sdk/syncio/pure.py @@ -87,6 +87,8 @@ def _http( response_data = response.read().decode("utf8") - return Response(json.loads(response_data) if response_data else {}) + return Response.build_from_dict( + json.loads(response_data) if response_data else {} + ) finally: conn.close() diff --git a/src/latch_sdk/syncio/requests.py b/src/latch_sdk/syncio/requests.py index c64560a..d580a0c 100644 --- a/src/latch_sdk/syncio/requests.py +++ b/src/latch_sdk/syncio/requests.py @@ -82,4 +82,4 @@ def _http( response.raise_for_status() - return Response(response.json() if response.text else {}) + return Response.build_from_dict(response.json() if response.text else {}) diff --git a/tests/asyncio/test_base.py b/tests/asyncio/test_base.py index 6391128..407769d 100644 --- a/tests/asyncio/test_base.py +++ b/tests/asyncio/test_base.py @@ -36,13 +36,13 @@ def setUp(self) -> None: return super().setUp() async def test_account_pair(self): - self.core.account_pair.return_value = Response( + self.core.account_pair.return_value = Response.build_from_dict( ResponseFactory.account_pair("test_account") ) self.assertEqual(await self.latch_sdk.account_pair("terwrw"), "test_account") async def test_account_pair_error_206_token_expired(self): - self.core.account_pair.return_value = Response( + self.core.account_pair.return_value = Response.build_from_dict( ResponseFactory.account_pair_error_206_token_expired() ) @@ -50,7 +50,7 @@ async def test_account_pair_error_206_token_expired(self): await self.latch_sdk.account_pair("terwrw") async def test_account_pair_error_205_already_paired(self): - self.core.account_pair.return_value = Response( + self.core.account_pair.return_value = Response.build_from_dict( ResponseFactory.account_pair_error_205_already_paired("test_account") ) @@ -62,7 +62,7 @@ async def test_account_pair_error_205_already_paired(self): async def test_totp_create(self): user_id = "example@tu.com" common_name = "Example Test" - self.core.totp_create.return_value = Response( + self.core.totp_create.return_value = Response.build_from_dict( ResponseFactory.totp_create_or_load( identity_id=user_id, identity_name=common_name ) @@ -75,7 +75,7 @@ async def test_totp_load(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" user_id = "example@tu.com" common_name = "Example Test" - self.core.totp_load.return_value = Response( + self.core.totp_load.return_value = Response.build_from_dict( ResponseFactory.totp_create_or_load( totp_id=totp_id, identity_id=user_id, identity_name=common_name ) @@ -90,14 +90,16 @@ async def test_totp_validate(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" code = "2435465" - self.core.totp_validate.return_value = Response(ResponseFactory.no_data()) + self.core.totp_validate.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue(await self.latch_sdk.totp_validate(totp_id, code)) async def test_totp_validate_error(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" code = "2435465" - self.core.totp_validate.return_value = Response( + self.core.totp_validate.return_value = Response.build_from_dict( ResponseFactory.totp_validate_error() ) @@ -107,13 +109,15 @@ async def test_totp_validate_error(self): async def test_totp_remove(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" - self.core.totp_remove.return_value = Response(ResponseFactory.no_data()) + self.core.totp_remove.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue(await self.latch_sdk.totp_remove(totp_id)) async def test_application_create(self): application_id = "12346578798654ds" name = "Example Test" - self.core.application_create.return_value = Response( + self.core.application_create.return_value = Response.build_from_dict( ResponseFactory.application_create(application_id=application_id) ) app = await self.latch_sdk.application_create(name) @@ -123,18 +127,22 @@ async def test_application_create(self): async def test_application_update(self): application_id = "12346578798654ds" name = "Example Test" - self.core.application_update.return_value = Response(ResponseFactory.no_data()) + self.core.application_update.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue( await self.latch_sdk.application_update(application_id, name=name) ) async def test_application_remove(self): application_id = "12346578798654ds" - self.core.application_remove.return_value = Response(ResponseFactory.no_data()) + self.core.application_remove.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue(await self.latch_sdk.application_remove(application_id)) async def test_application_list(self): - self.core.application_list.return_value = Response( + self.core.application_list.return_value = Response.build_from_dict( ResponseFactory.application_list() ) app_lst = await self.latch_sdk.application_list() diff --git a/tests/syncio/test_base.py b/tests/syncio/test_base.py index e0d052a..950e123 100644 --- a/tests/syncio/test_base.py +++ b/tests/syncio/test_base.py @@ -36,13 +36,13 @@ def setUp(self) -> None: return super().setUp() def test_account_pair(self): - self.core.account_pair.return_value = Response( + self.core.account_pair.return_value = Response.build_from_dict( ResponseFactory.account_pair("test_account") ) self.assertEqual(self.latch_sdk.account_pair("terwrw"), "test_account") def test_account_pair_error_206_token_expired(self): - self.core.account_pair.return_value = Response( + self.core.account_pair.return_value = Response.build_from_dict( ResponseFactory.account_pair_error_206_token_expired() ) @@ -50,7 +50,7 @@ def test_account_pair_error_206_token_expired(self): self.latch_sdk.account_pair("terwrw") def test_account_pair_error_205_already_paired(self): - self.core.account_pair.return_value = Response( + self.core.account_pair.return_value = Response.build_from_dict( ResponseFactory.account_pair_error_205_already_paired("test_account") ) @@ -62,7 +62,7 @@ def test_account_pair_error_205_already_paired(self): def test_totp_create(self): user_id = "example@tu.com" common_name = "Example Test" - self.core.totp_create.return_value = Response( + self.core.totp_create.return_value = Response.build_from_dict( ResponseFactory.totp_create_or_load( identity_id=user_id, identity_name=common_name ) @@ -75,7 +75,7 @@ def test_totp_load(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" user_id = "example@tu.com" common_name = "Example Test" - self.core.totp_load.return_value = Response( + self.core.totp_load.return_value = Response.build_from_dict( ResponseFactory.totp_create_or_load( totp_id=totp_id, identity_id=user_id, identity_name=common_name ) @@ -90,14 +90,16 @@ def test_totp_validate(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" code = "2435465" - self.core.totp_validate.return_value = Response(ResponseFactory.no_data()) + self.core.totp_validate.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue(self.latch_sdk.totp_validate(totp_id, code)) def test_totp_validate_error(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" code = "2435465" - self.core.totp_validate.return_value = Response( + self.core.totp_validate.return_value = Response.build_from_dict( ResponseFactory.totp_validate_error() ) @@ -107,13 +109,15 @@ def test_totp_validate_error(self): def test_totp_remove(self): totp_id = "435yujrhtgefqw34restghjdrsyetsfd" - self.core.totp_remove.return_value = Response(ResponseFactory.no_data()) + self.core.totp_remove.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue(self.latch_sdk.totp_remove(totp_id)) def test_application_create(self): application_id = "12346578798654ds" name = "Example Test" - self.core.application_create.return_value = Response( + self.core.application_create.return_value = Response.build_from_dict( ResponseFactory.application_create(application_id=application_id) ) app = self.latch_sdk.application_create(name) @@ -123,16 +127,20 @@ def test_application_create(self): def test_application_update(self): application_id = "12346578798654ds" name = "Example Test" - self.core.application_update.return_value = Response(ResponseFactory.no_data()) + self.core.application_update.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue(self.latch_sdk.application_update(application_id, name=name)) def test_application_remove(self): application_id = "12346578798654ds" - self.core.application_remove.return_value = Response(ResponseFactory.no_data()) + self.core.application_remove.return_value = Response.build_from_dict( + ResponseFactory.no_data() + ) self.assertTrue(self.latch_sdk.application_remove(application_id)) def test_application_list(self): - self.core.application_list.return_value = Response( + self.core.application_list.return_value = Response.build_from_dict( ResponseFactory.application_list() ) app_lst = self.latch_sdk.application_list() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..15505c5 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,37 @@ +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from unittest import TestCase + +from latch_sdk.exceptions import InvalidCredentials +from latch_sdk.models import Response + + +class ResponseTestCase(TestCase): + def test_no_error(self) -> None: + resp = Response.build_from_dict({"data": {}}) + + resp.raise_on_error() + + def test_with_error(self) -> None: + resp = Response.build_from_dict( + {"error": {"code": InvalidCredentials.CODE, "message": "test message"}} + ) + + with self.assertRaises(InvalidCredentials) as ex: + resp.raise_on_error() + + self.assertEqual(ex.exception.message, "test message") diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 28c6397..ee851f1 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -29,7 +29,6 @@ AccountDisabledBySubscription, ApplicationAlreadyPaired, ApplicationNotFound, - InvalidCredentials, MaxActionsExceed, TokenNotFound, ) @@ -46,7 +45,6 @@ ) from latch_sdk.sansio import ( LatchSansIO, - check_error, response_account_history, response_account_pair, response_account_status, @@ -64,33 +62,16 @@ from .factory import ResponseFactory -class CheckErrorTestCase(TestCase): - def test_no_error(self) -> None: - resp = Response({"data": {}}) - - check_error(resp) - - def test_with_error(self) -> None: - resp = Response( - {"error": {"code": InvalidCredentials.CODE, "message": "test message"}} - ) - - with self.assertRaises(InvalidCredentials) as ex: - check_error(resp) - - self.assertEqual(ex.exception.message, "test message") - - class ResponseAccountPairTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.account_pair("test_account")) + resp = Response.build_from_dict(ResponseFactory.account_pair("test_account")) account_id = response_account_pair(resp) self.assertEqual(account_id, "test_account") def test_with_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( ResponseFactory.account_pair_error_206_token_expired("test message") ) @@ -100,7 +81,7 @@ def test_with_error(self) -> None: self.assertEqual(ex.exception.message, "test message") def test_with_error_already_paired(self) -> None: - resp = Response( + resp = Response.build_from_dict( ResponseFactory.account_pair_error_205_already_paired( "test_account", "test message" ) @@ -115,12 +96,14 @@ def test_with_error_already_paired(self) -> None: class ResponseNoErrorTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.no_data()) + resp = Response.build_from_dict(ResponseFactory.no_data()) self.assertTrue(response_no_error(resp)) def test_with_error(self) -> None: - resp = Response(ResponseFactory.error(ApplicationNotFound.CODE, "test message")) + resp = Response.build_from_dict( + ResponseFactory.error(ApplicationNotFound.CODE, "test message") + ) with self.assertRaises(ApplicationNotFound) as ex: response_no_error(resp) @@ -130,7 +113,7 @@ def test_with_error(self) -> None: class ResponseAccountStatusTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.account_status_on("account_id")) + resp = Response.build_from_dict(ResponseFactory.account_status_on("account_id")) status = response_account_status(resp) @@ -139,7 +122,7 @@ def test_no_error(self) -> None: self.assertTrue(status.status) def test_require_otp(self) -> None: - resp = Response( + resp = Response.build_from_dict( ResponseFactory.account_status_on_two_factor("account_id", "123456") ) @@ -152,7 +135,7 @@ def test_require_otp(self) -> None: self.assertEqual(status.two_factor.token, "123456") # type: ignore def test_with_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( {"error": {"code": MaxActionsExceed.CODE, "message": "test message"}} ) @@ -162,7 +145,7 @@ def test_with_error(self) -> None: self.assertEqual(ex.exception.message, "test message") def test_with_error702_disabled_by_subscription(self) -> None: - resp = Response( + resp = Response.build_from_dict( ResponseFactory.account_status_on_error_702_disabled_by_subscription() ) @@ -174,7 +157,7 @@ def test_with_error702_disabled_by_subscription(self) -> None: class ResponseOperationTestCase(TestCase): def test_no_error_operation_key(self) -> None: - resp = Response( + resp = Response.build_from_dict( {"data": {"operations": {"operation_id": {"name": "Operation Test"}}}} ) @@ -185,7 +168,9 @@ def test_no_error_operation_key(self) -> None: self.assertEqual(operation.name, "Operation Test") def test_no_error_no_key(self) -> None: - resp = Response({"data": {"operation_id": {"name": "Operation Test"}}}) + resp = Response.build_from_dict( + {"data": {"operation_id": {"name": "Operation Test"}}} + ) operation = response_operation(resp) @@ -194,7 +179,7 @@ def test_no_error_no_key(self) -> None: self.assertEqual(operation.name, "Operation Test") def test_with_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -206,7 +191,7 @@ def test_with_error(self) -> None: class ResponseOperationListTestCase(TestCase): def test_no_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( { "data": { "operations": { @@ -233,7 +218,7 @@ def test_no_error(self) -> None: next(operation_list) def test_no_error_empty(self) -> None: - resp = Response({"data": {}}) + resp = Response.build_from_dict({"data": {}}) operation_list = iter(response_operation_list(resp)) @@ -241,7 +226,7 @@ def test_no_error_empty(self) -> None: next(operation_list) def test_with_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -253,7 +238,7 @@ def test_with_error(self) -> None: class ResponseInstanceListTestCase(TestCase): def test_no_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( { "data": { "instance_id_1": {"name": "Instance Test 1"}, @@ -278,7 +263,7 @@ def test_no_error(self) -> None: next(instance_list) def test_no_error_empty(self) -> None: - resp = Response({"data": {}}) + resp = Response.build_from_dict({"data": {}}) operation_list = iter(response_instance_list(resp)) @@ -286,7 +271,7 @@ def test_no_error_empty(self) -> None: next(operation_list) def test_with_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -298,12 +283,12 @@ def test_with_error(self) -> None: class ResponseInstanceCreateTestCase(TestCase): def test_no_error(self) -> None: - resp = Response({"data": {"instance_id_1": "Instance Test 1"}}) + resp = Response.build_from_dict({"data": {"instance_id_1": "Instance Test 1"}}) self.assertEqual(response_instance_create(resp), "instance_id_1") def test_with_error(self) -> None: - resp = Response( + resp = Response.build_from_dict( {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}} ) @@ -315,7 +300,7 @@ def test_with_error(self) -> None: class ResponseAccountHistoryTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.account_history()) + resp = Response.build_from_dict(ResponseFactory.account_history()) data = response_account_history(resp) @@ -324,7 +309,7 @@ def test_no_error(self) -> None: class ResponseTotpTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.totp_create_or_load()) + resp = Response.build_from_dict(ResponseFactory.totp_create_or_load()) data = response_totp(resp) @@ -343,7 +328,7 @@ def test_no_error(self) -> None: class ResponseApplicationCreateTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.application_create()) + resp = Response.build_from_dict(ResponseFactory.application_create()) data = response_application_create(resp) @@ -354,7 +339,7 @@ def test_no_error(self) -> None: class ResponseApplicationListTestCase(TestCase): def test_no_error(self) -> None: - resp = Response(ResponseFactory.application_list()) + resp = Response.build_from_dict(ResponseFactory.application_list()) data = response_application_list(resp) @@ -368,7 +353,7 @@ def test_no_error(self) -> None: self.assertTrue(has_data, "No applications") def test_empty(self) -> None: - resp = Response(ResponseFactory.application_list_empty()) + resp = Response.build_from_dict(ResponseFactory.application_list_empty()) data = response_application_list(resp) @@ -692,7 +677,7 @@ def _http_cb( self.assertIsNone(params) - return Response({"data": {"accountId": "latch_account_id"}}) + return Response.build_from_dict({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) @@ -730,7 +715,7 @@ def _http_cb( }, ) - return Response({"data": {"accountId": "latch_account_id"}}) + return Response.build_from_dict({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) @@ -771,7 +756,7 @@ def _http_cb( }, ) - return Response({"data": {"accountId": "latch_account_id"}}) + return Response.build_from_dict({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) @@ -804,7 +789,7 @@ def _http_cb( self.assertIsNone(params) - return Response({"data": {"accountId": "latch_account_id"}}) + return Response.build_from_dict({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) @@ -834,7 +819,7 @@ def _http_cb( self.assertIsNone(params) - return Response( + return Response.build_from_dict( ResponseFactory.account_pair_error_205_already_paired( "latch_account_id" ) @@ -879,7 +864,7 @@ def _http_cb( }, ) - return Response({"data": {"accountId": "latch_account_id"}}) + return Response.build_from_dict({"data": {"accountId": "latch_account_id"}}) http_cb = Mock(side_effect=_http_cb) @@ -1077,7 +1062,7 @@ def _http_cb( ), ) - return Response({"data": {account_id: {"status": "on"}}}) + return Response.build_from_dict({"data": {account_id: {"status": "on"}}}) http_cb = Mock(side_effect=_http_cb) @@ -1113,7 +1098,7 @@ def _http_cb( self.assertEqual( params, {"phoneNumber": phone_number, "location": location} ) - return Response({"data": {account_id: {"status": "on"}}}) + return Response.build_from_dict({"data": {account_id: {"status": "on"}}}) http_cb = Mock(side_effect=_http_cb) @@ -1145,7 +1130,7 @@ def _http_cb( self.assertEqual(path, f"/api/3.0/status/{account_id}") self.assertEqual(params, {"otp": otp, "msg": msg}) - return Response({"data": {account_id: {"status": "on"}}}) + return Response.build_from_dict({"data": {account_id: {"status": "on"}}}) http_cb = Mock(side_effect=_http_cb) @@ -1176,7 +1161,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1239,7 +1224,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1306,7 +1291,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1336,7 +1321,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1368,7 +1353,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1399,7 +1384,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1439,7 +1424,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1477,7 +1462,7 @@ def _http_cb( self.assertIsNone(params) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1507,7 +1492,7 @@ def _http_cb( self.assertEqual( params, {"phoneNumber": phone_number, "commonName": common_name} ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1565,7 +1550,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1600,7 +1585,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1645,7 +1630,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1680,7 +1665,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1719,7 +1704,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1756,7 +1741,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1788,7 +1773,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1820,7 +1805,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1854,7 +1839,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1887,7 +1872,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1921,7 +1906,7 @@ def _http_cb( self.assertEqual(params, {"instances": "new_instance"}) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1955,7 +1940,7 @@ def _http_cb( self.assertEqual(params, {"instances": "new_instance"}) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -1994,7 +1979,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -2028,7 +2013,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -2064,7 +2049,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -2104,7 +2089,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -2147,7 +2132,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -2203,7 +2188,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -2237,7 +2222,7 @@ def _http_cb( params, ) - return Response({}) + return Response.build_from_dict({}) http_cb = Mock(side_effect=_http_cb) @@ -2269,7 +2254,7 @@ def _http_cb( params, {"commonName": "new_totp", "userId": "ryhtggfdwdffhgrd"} ) - return Response(ResponseFactory.totp_create_or_load()) + return Response.build_from_dict(ResponseFactory.totp_create_or_load()) http_cb = Mock(side_effect=_http_cb) @@ -2302,7 +2287,7 @@ def _http_cb( self.assertIsNone(params) - return Response(ResponseFactory.totp_create_or_load()) + return Response.build_from_dict(ResponseFactory.totp_create_or_load()) http_cb = Mock(side_effect=_http_cb) @@ -2335,7 +2320,7 @@ def _http_cb( self.assertEqual(params, {"code": code}) - return Response(ResponseFactory.totp_validate_error()) + return Response.build_from_dict(ResponseFactory.totp_validate_error()) http_cb = Mock(side_effect=_http_cb) @@ -2365,7 +2350,7 @@ def _http_cb( self.assertIsNone(params) - return Response(ResponseFactory.totp_validate_error()) + return Response.build_from_dict(ResponseFactory.totp_validate_error()) http_cb = Mock(side_effect=_http_cb) @@ -2393,7 +2378,7 @@ def _http_cb( self.assertIsNone(params) - return Response(ResponseFactory.application_list()) + return Response.build_from_dict(ResponseFactory.application_list()) http_cb = Mock(side_effect=_http_cb) @@ -2436,7 +2421,7 @@ def _http_cb( }, ) - return Response(ResponseFactory.application_create()) + return Response.build_from_dict(ResponseFactory.application_create()) http_cb = Mock(side_effect=_http_cb) @@ -2481,7 +2466,7 @@ def _http_cb( }, ) - return Response(ResponseFactory.application_create()) + return Response.build_from_dict(ResponseFactory.application_create()) http_cb = Mock(side_effect=_http_cb) @@ -2525,7 +2510,7 @@ def _http_cb( }, ) - return Response(ResponseFactory.no_data()) + return Response.build_from_dict(ResponseFactory.no_data()) http_cb = Mock(side_effect=_http_cb) @@ -2576,7 +2561,7 @@ def _http_cb( self.assertIsNone(params) - return Response(ResponseFactory.application_list()) + return Response.build_from_dict(ResponseFactory.application_list()) http_cb = Mock(side_effect=_http_cb) From 6c17fedfe839a8897faf33b5f7f0c81d66022dfd Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 11:52:50 +0100 Subject: [PATCH 17/25] Added authorization control status api --- README.md | 19 ++---- pyproject.toml | 2 +- src/latch_sdk/asyncio/base.py | 9 ++- src/latch_sdk/cli/__init__.py | 2 + src/latch_sdk/cli/authorization_control.py | 55 +++++++++++++++ src/latch_sdk/cli/renders.py | 9 +++ src/latch_sdk/sansio.py | 78 ++++++++++++++++++---- src/latch_sdk/syncio/base.py | 9 ++- src/latch_sdk/utils.py | 6 +- tests/factory.py | 4 ++ tests/test_sansio.py | 69 ++++++++++++++++++- 11 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 src/latch_sdk/cli/authorization_control.py diff --git a/README.md b/README.md index 7c0cd04..172f3b4 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ * Python. -* Read API documentation (https://latch.telefonica.com/www/developers/doc_api). +* Read API documentation (https://latch.tu.com/www/developers/doc_api). -* To get the "Application ID" and "Secret", (fundamental values for integrating Latch in any application), it’s necessary to register a developer account in Latch's website: https://latch.telefonica.com. On the upper right side, click on "Developer area" +* To get the "Application ID" and "Secret", (fundamental values for integrating Latch in any application), it’s necessary to register a developer account in Latch's website: https://latch.tu.com. On the upper right side, click on "Developer area" #### USING THE SDK IN PYTHON #### @@ -39,9 +39,9 @@ * Call to Latch Server. Pairing will return an account id that you should store for future api calls ```python - account_id = api.pair("PAIRING_CODE_HERE") - status = api.status(account_id) - assert api.unpair(account_id) is True + account_id = api.account_pair("PAIRING_CODE_HERE") + status = api.account_status(account_id) + assert api.account_unpair(account_id) is True ``` @@ -58,11 +58,9 @@ For using the Python SDK within an Web3 service, you must complain with the foll Once you have your developer Latch account created, you must logging in the website. -[Steps to add new web3 app in latch-website](doc/Latch_WEB3_Apps.pdf) - We add a new method to pair the web3 applications, now we have two new parameters. The two additional parameters are: -- WEB3WALLET: The Ethereum-based address wallet for the user that wants to pair the service. +- WEB3ACCOUNT: The Ethereum-based address account for the user that wants to pair the service. - WEB3SIGNATURE: A proof-of-ownership signature of a constant, in order to verify that the user owns the private key of the wallet. You can use https://etherscan.io/verifiedSignatures# to sign the following message: - MESSAGE TO SIGN : "Latch-Web3" @@ -71,8 +69,5 @@ The two additional parameters are: ``` python api = LatchSDK(Latch("APP_ID_HERE", "SECRET_KEY_HERE")) # PAIR - account_id = api.pair(pairing_code, WEB3WALLET, WEB3SIGNATURE) + account_id = api.account_pair(pairing_code, WEB3ACCOUNT, WEB3SIGNATURE) ``` - - -You have an example of use in the file [example for web3 app](src/test_sdk_latch_web3.py) diff --git a/pyproject.toml b/pyproject.toml index f692f78..56f0ebc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "latch-sdk-telefonica" version = "3.0.0" authors = [ - { name="Telefonica Latch", email="soporte.latch@telefonica.com" }, + { name="Telefonica Latch", email="soporte.latch@tu.com" }, ] description = "Latch SDK for Pyhton" readme = "README.md" diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index 50eca7a..2771f2a 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -32,6 +32,7 @@ response_account_status, response_application_create, response_application_list, + response_authorization_control_status, response_instance_create, response_instance_list, response_no_error, @@ -57,7 +58,9 @@ def wrap_method( Wrap an action and response processor in a single method. """ - @wraps_and_replace_return(meth, factory.__annotations__["return"]) + @wraps_and_replace_return( + meth, factory.__annotations__["return"], factory_docs=factory.__doc__ + ) async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) @@ -103,6 +106,10 @@ class LatchSDK(LatchSDKSansIO[Awaitable[Response]]): totp_validate = wrap_method(response_no_error, BaseLatch.totp_validate) totp_remove = wrap_method(response_no_error, BaseLatch.totp_remove) + authorization_control_status = wrap_method( + response_authorization_control_status, BaseLatch.authorization_control_status + ) + application_list = wrap_method( response_application_list, BaseLatch.application_list ) diff --git a/src/latch_sdk/cli/__init__.py b/src/latch_sdk/cli/__init__.py index 3e4673e..471067c 100644 --- a/src/latch_sdk/cli/__init__.py +++ b/src/latch_sdk/cli/__init__.py @@ -19,6 +19,7 @@ from .account import account from .application import application +from .authorization_control import authorization_control from .operation import operation from .totp import totp from ..syncio import LatchSDK @@ -57,6 +58,7 @@ def cli(ctx: click.Context, app_id: str, secret: str): cli.add_command(account) cli.add_command(operation) cli.add_command(totp) +cli.add_command(authorization_control) cli.add_command(application) if __name__ == "__main__": diff --git a/src/latch_sdk/cli/authorization_control.py b/src/latch_sdk/cli/authorization_control.py new file mode 100644 index 0000000..7f6af8b --- /dev/null +++ b/src/latch_sdk/cli/authorization_control.py @@ -0,0 +1,55 @@ +# Copyright (C) 2025 Telefonica Digital España S.L. +# +# This library is free software you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +import click + +from .renders import render_authorization_control_status +from .utils import pass_latch_sdk +from ..syncio import LatchSDK + + +@click.group("authorization-control") +def authorization_control(): + """Authorization Control actions""" + + +@authorization_control.command() +@click.argument("CONTROL_ID", type=str, required=True) +@click.option( + "--silent", + default=False, + is_flag=True, + required=False, + help="Do not push notification", +) +@click.option("--location", "-l", type=str, required=False, help="Location identifier") +@pass_latch_sdk +def status( + latch: LatchSDK, + control_id: str, + silent: bool, + location: "str | None", +): + """ + Get authorization control status + """ + status = latch.authorization_control_status( + control_id, + send_notifications=not silent, + location=location, + ) + + render_authorization_control_status(status) diff --git a/src/latch_sdk/cli/renders.py b/src/latch_sdk/cli/renders.py index a64aba2..347b20f 100644 --- a/src/latch_sdk/cli/renders.py +++ b/src/latch_sdk/cli/renders.py @@ -57,6 +57,15 @@ def render_status(status: Status): click.echo("-" * 50) +def render_authorization_control_status(status: bool): + if status: + click.secho("Authorization control is open: Operations are allowed", bg="green") + else: + click.secho( + "Authorization control is closed: Operations are not allowed", bg="red" + ) + + def render_operations(operations: Iterable[Operation]): click.echo("Operations:") click.echo("-" * 50) diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index fa6652c..ea67285 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -88,6 +88,7 @@ class Paths(TypedDict): application: str instance: str totp: str + authorization_control_status: str class LatchSansIO(ABC, Generic[TResponse]): @@ -104,6 +105,7 @@ class LatchSansIO(ABC, Generic[TResponse]): API_PATH_APPLICATION_PATTERN = "/api/${version}/application" API_PATH_INSTANCE_PATTERN = "/api/${version}/instance" API_PATH_TOTP_PATTERN = "/api/${version}/totps" + API_PATH_AUTHORIZATION_CONTROL_STATUS_PATTERN = "/api/${version}/control-status" AUTHORIZATION_HEADER_NAME = "Authorization" DATE_HEADER_NAME = "X-11Paths-Date" @@ -271,6 +273,9 @@ def build_paths(cls, api_version: str) -> Paths: "totp": Template(cls.API_PATH_TOTP_PATTERN).safe_substitute( {"version": api_version} ), + "authorization_control_status": Template( + cls.API_PATH_AUTHORIZATION_CONTROL_STATUS_PATTERN + ).safe_substitute({"version": api_version}), } @classmethod @@ -473,10 +478,18 @@ def _prepare_http( path: str, *, headers: "Mapping[str, str] | None" = None, + query: "Mapping[str, str] | None" = None, params: "Mapping[str, str] | None" = None, ) -> TResponse: + from urllib.parse import urlencode + + path = "?".join((path, urlencode(query, doseq=True))) if query else path + headers = self.authentication_headers( - method, path, headers=headers, params=params + method, + path, + headers=headers, + params=params, ) if method == "POST" or method == "PUT": headers["Content-type"] = "application/x-www-form-urlencoded" @@ -765,6 +778,36 @@ def account_history( ), ) + def authorization_control_status( + self, + control_id: str, + *, + location: "str | None" = None, + send_notifications: bool = True, + ): + """ + Get authorization control status. + + :param control_id: The Authorization control identifier. + :param location: Authorized location. + :param send_notification: Whether to send notification to other participants or not. + """ + query = {"sendNotifications": "1" if send_notifications else "0"} + + if location: + query["location"] = location + + return self._prepare_http( + "GET", + "/".join( + ( + self._paths["authorization_control_status"], + control_id, + ) + ), + query=query, + ) + def operation_create( self, parent_id: str, @@ -1098,7 +1141,7 @@ def __init__(self, core: LatchSansIO[TResponse]): def response_no_error(resp: Response) -> Literal[True]: """ - Returns `True` if not error + :return: `True` if not error. """ resp.raise_on_error() @@ -1107,7 +1150,7 @@ def response_no_error(resp: Response) -> Literal[True]: def response_account_pair(resp: Response) -> str: """ - Gets accountId from response + :return: Account identifier. """ try: resp.raise_on_error() @@ -1131,7 +1174,7 @@ def _build_account_status_from_response(resp: Response): def response_account_status(resp: Response) -> Status: """ - Builds status object from response + :return: Status object. """ try: resp.raise_on_error() @@ -1145,7 +1188,7 @@ def response_account_status(resp: Response) -> Status: def response_account_history(resp: Response) -> HistoryResponse: """ - Builds history response object from response + :return: History response object. """ resp.raise_on_error() @@ -1154,9 +1197,20 @@ def response_account_history(resp: Response) -> HistoryResponse: return HistoryResponse.build_from_dict(resp.data) +def response_authorization_control_status(resp: Response) -> bool: + """ + :return: False if authorization control is locked, and True otherwise. + """ + resp.raise_on_error() + + assert resp.data is not None, "No error or data" + + return resp.data.get("status", "off") == "on" + + def response_operation(resp: Response) -> Operation: """ - Builds operation object from response + :return: Operation object. """ resp.raise_on_error() @@ -1175,7 +1229,7 @@ def response_operation(resp: Response) -> Operation: def response_operation_list(resp: Response) -> Iterable[Operation]: """ - Builds operation object list from response + :return: Operation object list. """ resp.raise_on_error() @@ -1189,7 +1243,7 @@ def response_operation_list(resp: Response) -> Iterable[Operation]: def response_instance_list(resp: Response) -> Iterable[Instance]: """ - Builds instance object list from response + :return: Instance object list. """ resp.raise_on_error() @@ -1200,7 +1254,7 @@ def response_instance_list(resp: Response) -> Iterable[Instance]: def response_instance_create(resp: Response) -> str: """ - Gets instance identifier from response + :return: Instance identifier. """ resp.raise_on_error() @@ -1211,7 +1265,7 @@ def response_instance_create(resp: Response) -> str: def response_totp(resp: Response) -> TOTP: """ - Gets TOTP object from response + :return: TOTP object. """ resp.raise_on_error() @@ -1222,7 +1276,7 @@ def response_totp(resp: Response) -> TOTP: def response_application_create(resp: Response) -> ApplicationCreateResponse: """ - Application creation information from response + :return: Application creation information. """ resp.raise_on_error() @@ -1233,7 +1287,7 @@ def response_application_create(resp: Response) -> ApplicationCreateResponse: def response_application_list(resp: Response) -> Iterable[Application]: """ - Builds application object list from response + :return: Application object list. """ resp.raise_on_error() diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index 6a86816..5870950 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -32,6 +32,7 @@ response_account_status, response_application_create, response_application_list, + response_authorization_control_status, response_instance_create, response_instance_list, response_no_error, @@ -57,7 +58,9 @@ def wrap_method( Wrap an action and response processor in a single method. """ - @wraps_and_replace_return(meth, factory.__annotations__["return"]) + @wraps_and_replace_return( + meth, factory.__annotations__["return"], factory_docs=factory.__doc__ + ) def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) @@ -103,6 +106,10 @@ class LatchSDK(LatchSDKSansIO[Response]): totp_validate = wrap_method(response_no_error, BaseLatch.totp_validate) totp_remove = wrap_method(response_no_error, BaseLatch.totp_remove) + authorization_control_status = wrap_method( + response_authorization_control_status, BaseLatch.authorization_control_status + ) + application_list = wrap_method( response_application_list, BaseLatch.application_list ) diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index aa81059..cbe8450 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -54,6 +54,8 @@ def sign_data( def wraps_and_replace_return( meth: "Callable[Concatenate[Any, P], Any]", return_type: T, + *, + factory_docs: str | None = None, ) -> ( "Callable[[Callable[Concatenate[TSelf, P], T]], Callable[Concatenate[TSelf, P], T]]" ): @@ -63,7 +65,9 @@ def wraps_and_replace_return( def inner(f) -> "Callable[Concatenate[TSelf, P], T]": wrapped = wraps(meth)(f) - wrapped.__doc__ = meth.__doc__ + wrapped.__doc__ = "\n\n".join((meth.__doc__ or "", factory_docs or "")).strip( + "\n" + ) wrapped.__name__ = meth.__name__ wrapped.__annotations__["return"] = return_type diff --git a/tests/factory.py b/tests/factory.py index 30cea92..b90ca72 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -225,6 +225,10 @@ def totp_create_or_load( def totp_validate_error(cls): return {"error": {"code": 306, "message": "Invalid totp code"}} + @classmethod + def authorization_control_status(cls, status: bool = True): + return {"data": {"status": "on" if status else "off"}} + @classmethod def application_create(cls, *, application_id: str = "aaaaaaaaaaaphhhhhhh"): return { diff --git a/tests/test_sansio.py b/tests/test_sansio.py index ee851f1..cee3580 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -50,6 +50,7 @@ response_account_status, response_application_create, response_application_list, + response_authorization_control_status, response_instance_create, response_instance_list, response_no_error, @@ -326,6 +327,22 @@ def test_no_error(self) -> None: self.assertIsNotNone(data.disabled_by_subscription_limit) +class ResponseAuthorizationControlStatusTestCase(TestCase): + def test_status_open(self) -> None: + resp = Response.build_from_dict( + ResponseFactory.authorization_control_status(True) + ) + + self.assertTrue(response_authorization_control_status(resp)) + + def test_status_close(self) -> None: + resp = Response.build_from_dict( + ResponseFactory.authorization_control_status(False) + ) + + self.assertFalse(response_authorization_control_status(resp)) + + class ResponseApplicationCreateTestCase(TestCase): def test_no_error(self) -> None: resp = Response.build_from_dict(ResponseFactory.application_create()) @@ -379,6 +396,7 @@ def test_build_paths(self): "account_unlock": "/api/v1/unlock", "account_unpair": "/api/v1/unpair", "account_metadata": "/api/v1/aliasMetadata", + "authorization_control_status": "/api/v1/control-status", }, ) @@ -398,6 +416,7 @@ def test_build_paths(self): "account_unlock": "/api/p2/unlock", "account_unpair": "/api/p2/unpair", "account_metadata": "/api/p2/aliasMetadata", + "authorization_control_status": "/api/p2/control-status", }, ) @@ -2361,7 +2380,52 @@ def _http_cb( http_cb.assert_called_once() - def test_application_list(self) -> None: + @parametrize( + "send_notifications,location,query", + [ + (True, None, "?sendNotifications=1"), + (True, "my_location", "?sendNotifications=1&location=my_location"), + (False, "my_location", "?sendNotifications=0&location=my_location"), + (False, None, "?sendNotifications=0"), + ], + ) # type: ignore + def test_authorization_control_status( + self, send_notifications: bool, location: str | None, query: str + ): + control_id = "rtjgnbvc345tyhbdvstr" + + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + f"/api/3.0/control-status/{control_id}{query}", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return Response.build_from_dict( + ResponseFactory.authorization_control_status() + ) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.authorization_control_status( + control_id, location=location, send_notifications=send_notifications + ) + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + + def test_application_list(self): def _http_cb( method: str, path: str, @@ -2753,3 +2817,6 @@ def test_build_url_http_port(self) -> None: latch.build_url("/test", query="param1=1¶m2=2"), "http://foo.bar.com:8080/test?param1=1¶m2=2", ) + + +# type: ignore From e7e1bcf92d0de6e40f2c69bb2ae85c1dc15b9458 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 11:54:13 +0100 Subject: [PATCH 18/25] Make compatible py3.9 --- src/latch_sdk/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index cbe8450..76e3429 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -55,7 +55,7 @@ def wraps_and_replace_return( meth: "Callable[Concatenate[Any, P], Any]", return_type: T, *, - factory_docs: str | None = None, + factory_docs: "str | None" = None, ) -> ( "Callable[[Callable[Concatenate[TSelf, P], T]], Callable[Concatenate[TSelf, P], T]]" ): From 1da4aa86c3ec565602cc8293191e761d3ebb427e Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 11:56:23 +0100 Subject: [PATCH 19/25] Make compatible py3.9 --- tests/test_sansio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sansio.py b/tests/test_sansio.py index cee3580..92f254a 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -2390,7 +2390,7 @@ def _http_cb( ], ) # type: ignore def test_authorization_control_status( - self, send_notifications: bool, location: str | None, query: str + self, send_notifications: bool, location: "str | None", query: str ): control_id = "rtjgnbvc345tyhbdvstr" From f8305654e65f940a93560715d4c223d48fa2d839 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 12:41:06 +0100 Subject: [PATCH 20/25] Better docs --- docs/source/conf.py | 2 ++ src/latch_sdk/utils.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 9ff1303..0d60f88 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -54,6 +54,8 @@ set_type_checking_flag = True python_use_unqualified_type_names = True +python_maximum_signature_line_length = 80 +python_display_short_literal_types = True autodoc_default_options = { "members": "", diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index 76e3429..6a702e5 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -36,6 +36,9 @@ def sign_data( ) -> bytes: """ Signs data using a secret + + :param secret: Secret to use to sign `data`. + :param data: Data to sign. """ import hmac @@ -61,6 +64,10 @@ def wraps_and_replace_return( ): """ Wraps a method and replace return type. + + :param meth: Request constructor method. + :param return_type: Return type for new method. It should be the return type of factory. + :param factory_docs: Documentation from factory function to be appended to `meth` docstring. """ def inner(f) -> "Callable[Concatenate[TSelf, P], T]": From eae17cbb52ce063293eb41508334b4f2ef6743e4 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 16:23:49 +0100 Subject: [PATCH 21/25] Added get subscription and more docs --- docs/source/api/index.rst | 3 +- docs/source/api/models.rst | 1 + docs/source/api/sansio.rst | 9 + docs/source/api/syncio.rst | 1 + docs/source/conf.py | 7 +- src/latch_sdk/asyncio/aiohttp.py | 4 +- src/latch_sdk/asyncio/base.py | 3 + src/latch_sdk/asyncio/httpx.py | 4 +- src/latch_sdk/cli/__init__.py | 14 ++ src/latch_sdk/cli/account.py | 45 ++--- src/latch_sdk/cli/application.py | 16 +- src/latch_sdk/cli/authorization_control.py | 4 +- src/latch_sdk/cli/instance.py | 10 +- src/latch_sdk/cli/operation.py | 10 +- src/latch_sdk/cli/renders.py | 31 +++ src/latch_sdk/exceptions.py | 6 +- src/latch_sdk/models.py | 87 +++++++-- src/latch_sdk/sansio.py | 213 +++++++++++++++------ src/latch_sdk/syncio/base.py | 3 + src/latch_sdk/syncio/httpx.py | 4 +- src/latch_sdk/utils.py | 6 +- tests/factory.py | 16 ++ tests/test_sansio.py | 85 +++++++- 23 files changed, 460 insertions(+), 122 deletions(-) create mode 100644 docs/source/api/sansio.rst diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index f07b75c..61881e5 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -11,4 +11,5 @@ API reference asyncio models exceptions - utils \ No newline at end of file + utils + sansio \ No newline at end of file diff --git a/docs/source/api/models.rst b/docs/source/api/models.rst index 9a98a59..d9d05af 100644 --- a/docs/source/api/models.rst +++ b/docs/source/api/models.rst @@ -7,3 +7,4 @@ Models .. automodule:: latch_sdk.models :members: + :show-inheritance: none diff --git a/docs/source/api/sansio.rst b/docs/source/api/sansio.rst new file mode 100644 index 0000000..f972b39 --- /dev/null +++ b/docs/source/api/sansio.rst @@ -0,0 +1,9 @@ +=========== +Sans-IO API +=========== + + +.. rubric:: **Module:** `latch_sdk.sansio` + +.. automodule:: latch_sdk.sansio + :members: diff --git a/docs/source/api/syncio.rst b/docs/source/api/syncio.rst index 9538bf7..9d7b0fb 100644 --- a/docs/source/api/syncio.rst +++ b/docs/source/api/syncio.rst @@ -38,6 +38,7 @@ Using standard library :members: :show-inheritance: + ...................... Using requests library ...................... diff --git a/docs/source/conf.py b/docs/source/conf.py index 0d60f88..abc1c9d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -43,11 +43,11 @@ # -- Autodoc config ----------------------------------------------- -autodoc_typehints = "description" +# autodoc_typehints = "description" autodoc_type_aliases = {"Command": "click.Command"} -autodoc_member_order = "groupwise" +autodoc_member_order = "bysource" add_module_names = True @@ -57,11 +57,14 @@ python_maximum_signature_line_length = 80 python_display_short_literal_types = True +autoclass_content = "both" + autodoc_default_options = { "members": "", "member-order": "bysource", "undoc-members": True, "show-inheritance": True, + "autoclass_content": "class", } diff --git a/src/latch_sdk/asyncio/aiohttp.py b/src/latch_sdk/asyncio/aiohttp.py index 4f2b6d3..c765a08 100644 --- a/src/latch_sdk/asyncio/aiohttp.py +++ b/src/latch_sdk/asyncio/aiohttp.py @@ -26,6 +26,8 @@ .. warning:: If extra requirements are no satisfied an error will rise on module import. """ +from typing import Optional + from .base import BaseLatch from ..models import Response @@ -53,7 +55,7 @@ class Latch(BaseLatch): _session: ClientSession def _reconfigure_session(self) -> None: - proxy: "str | None" = None + proxy: Optional[str] = None if self.proxy_host: proxy = f"http://{self.proxy_host}" if self.proxy_port: diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index 2771f2a..b95eb5f 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -38,6 +38,7 @@ response_no_error, response_operation, response_operation_list, + response_subscription, response_totp, ) from ..utils import wraps_and_replace_return @@ -110,6 +111,8 @@ class LatchSDK(LatchSDKSansIO[Awaitable[Response]]): response_authorization_control_status, BaseLatch.authorization_control_status ) + subscription_load = wrap_method(response_subscription, BaseLatch.subscription_load) + application_list = wrap_method( response_application_list, BaseLatch.application_list ) diff --git a/src/latch_sdk/asyncio/httpx.py b/src/latch_sdk/asyncio/httpx.py index 922a8ee..4cf6107 100644 --- a/src/latch_sdk/asyncio/httpx.py +++ b/src/latch_sdk/asyncio/httpx.py @@ -35,6 +35,8 @@ ) raise +from typing import Optional + from .base import BaseLatch from ..models import Response @@ -52,7 +54,7 @@ class Latch(BaseLatch): _session: AsyncClient def _reconfigure_session(self) -> None: - proxy: "str | None" = None + proxy: Optional[str] = None if self.proxy_host: proxy = f"http://{self.proxy_host}" if self.proxy_port: diff --git a/src/latch_sdk/cli/__init__.py b/src/latch_sdk/cli/__init__.py index 471067c..7e5edd9 100644 --- a/src/latch_sdk/cli/__init__.py +++ b/src/latch_sdk/cli/__init__.py @@ -21,7 +21,9 @@ from .application import application from .authorization_control import authorization_control from .operation import operation +from .renders import render_subscription from .totp import totp +from .utils import pass_latch_sdk from ..syncio import LatchSDK from ..syncio.pure import Latch @@ -61,5 +63,17 @@ def cli(ctx: click.Context, app_id: str, secret: str): cli.add_command(authorization_control) cli.add_command(application) + +@cli.command("subscription") +@pass_latch_sdk +def subscription(latch: LatchSDK): + """ + Retrieve developer's subscription information. + """ + subscription = latch.subscription_load() + + render_subscription(subscription) + + if __name__ == "__main__": cli() diff --git a/src/latch_sdk/cli/account.py b/src/latch_sdk/cli/account.py index 30b60ff..dd1e579 100644 --- a/src/latch_sdk/cli/account.py +++ b/src/latch_sdk/cli/account.py @@ -16,6 +16,7 @@ from datetime import datetime +from typing import Optional import click @@ -49,10 +50,10 @@ def account(): def pair( latch: LatchSDK, token: str, - web3_account: "str | None" = None, - web3_signature: "str | None" = None, - common_name: "str | None" = None, - phone_number: "str | None" = None, + web3_account: Optional[str] = None, + web3_signature: Optional[str] = None, + common_name: Optional[str] = None, + phone_number: Optional[str] = None, ): """ Pair a new latch. @@ -80,10 +81,10 @@ def pair( def pair_with_id( latch: LatchSDK, account_id: str, - web3_account: "str | None" = None, - web3_signature: "str | None" = None, - common_name: "str | None" = None, - phone_number: "str | None" = None, + web3_account: Optional[str] = None, + web3_signature: Optional[str] = None, + common_name: Optional[str] = None, + phone_number: Optional[str] = None, ): """ Pair with user id a new latch. @@ -138,14 +139,14 @@ def unpair( def status( latch: LatchSDK, account_id: str, - operation_id: "str | None", - instance_id: "str | None", + operation_id: Optional[str], + instance_id: Optional[str], nootp: bool, silent: bool, - otp_code: "str | None", - otp_message: "str | None", - phone_number: "str | None", - location: "str | None", + otp_code: Optional[str], + otp_message: Optional[str], + phone_number: Optional[str], + location: Optional[str], ): """ Get latch status @@ -173,8 +174,8 @@ def status( def set_metadata( latch: LatchSDK, account_id: str, - common_name: "str | None" = None, - phone_number: "str | None" = None, + common_name: Optional[str] = None, + phone_number: Optional[str] = None, ): """Update account metadata""" latch.account_set_metadata( @@ -196,8 +197,8 @@ def set_metadata( def lock( latch: LatchSDK, account_id: str, - operation_id: "str | None", - instance_id: "str | None", + operation_id: Optional[str], + instance_id: Optional[str], ): """Lock a latch""" latch.account_lock( @@ -221,8 +222,8 @@ def lock( def unlock( latch: LatchSDK, account_id: str, - operation_id: "str | None", - instance_id: "str | None", + operation_id: Optional[str], + instance_id: Optional[str], ): """Unlock a latch""" latch.account_unlock( @@ -246,8 +247,8 @@ def unlock( def history( latch: LatchSDK, account_id: str, - from_dt: "datetime | None", - to_dt: "datetime | None", + from_dt: Optional[datetime], + to_dt: Optional[datetime], ): """Show latch actions history""" history = latch.account_history(account_id, from_dt=from_dt, to_dt=to_dt) diff --git a/src/latch_sdk/cli/application.py b/src/latch_sdk/cli/application.py index dd1f512..9263dc7 100644 --- a/src/latch_sdk/cli/application.py +++ b/src/latch_sdk/cli/application.py @@ -15,6 +15,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +from typing import Optional + import click from .renders import render_application_create_response, render_applications @@ -67,8 +69,8 @@ def create( name: str, two_factor: ExtraFeature, lock_on_request: ExtraFeature, - contact_mail: "str | None", - contact_phone: "str | None", + contact_mail: Optional[str], + contact_phone: Optional[str], ): """Create a new application""" application = latch.application_create( @@ -117,11 +119,11 @@ def create( def update( latch: LatchSDK, application_id: str, - name: "str | None", - two_factor: "ExtraFeature | None", - lock_on_request: "ExtraFeature | None", - contact_mail: "str | None", - contact_phone: "str | None", + name: Optional[str], + two_factor: Optional[ExtraFeature], + lock_on_request: Optional[ExtraFeature], + contact_mail: Optional[str], + contact_phone: Optional[str], ): """Update a given application""" latch.application_update( diff --git a/src/latch_sdk/cli/authorization_control.py b/src/latch_sdk/cli/authorization_control.py index 7f6af8b..445f6ef 100644 --- a/src/latch_sdk/cli/authorization_control.py +++ b/src/latch_sdk/cli/authorization_control.py @@ -14,6 +14,8 @@ # License along with this library if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +from typing import Optional + import click from .renders import render_authorization_control_status @@ -41,7 +43,7 @@ def status( latch: LatchSDK, control_id: str, silent: bool, - location: "str | None", + location: Optional[str], ): """ Get authorization control status diff --git a/src/latch_sdk/cli/instance.py b/src/latch_sdk/cli/instance.py index a1f89a6..de91db7 100644 --- a/src/latch_sdk/cli/instance.py +++ b/src/latch_sdk/cli/instance.py @@ -15,6 +15,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +from typing import Optional + import click from .renders import render_instances @@ -30,7 +32,7 @@ "--operation-id", "-o", type=str, required=False, help="Operation identifier" ) @click.pass_context -def instance(ctx: click.Context, account_id: str, operation_id: "str | None"): +def instance(ctx: click.Context, account_id: str, operation_id: Optional[str]): """Instances actions""" ctx.obj = {"account_id": account_id, "operation_id": operation_id} @@ -87,9 +89,9 @@ def update( latch: LatchSDK, ctx: click.Context, instance_id: str, - name: "str | None", - two_factor: "ExtraFeature | None", - lock_on_request: "ExtraFeature | None", + name: Optional[str], + two_factor: Optional[ExtraFeature], + lock_on_request: Optional[ExtraFeature], ): """Update a given instance""" latch.instance_update( diff --git a/src/latch_sdk/cli/operation.py b/src/latch_sdk/cli/operation.py index 99100ff..1869eaf 100644 --- a/src/latch_sdk/cli/operation.py +++ b/src/latch_sdk/cli/operation.py @@ -15,6 +15,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +from typing import Optional + import click from .renders import ( @@ -37,7 +39,7 @@ def operation(): "--parent-id", "-p", type=str, required=False, help="Parent operation identifier" ) @pass_latch_sdk -def lst(latch: LatchSDK, parent_id: "str | None"): +def lst(latch: LatchSDK, parent_id: Optional[str]): """Print available operations list""" operations = latch.operation_list(parent_id=parent_id) @@ -109,9 +111,9 @@ def create( def update( latch: LatchSDK, operation_id: str, - name: "str | None", - two_factor: "ExtraFeature | None", - lock_on_request: "ExtraFeature | None", + name: Optional[str], + two_factor: Optional[ExtraFeature], + lock_on_request: Optional[ExtraFeature], ): """Update a given operation""" latch.operation_update( diff --git a/src/latch_sdk/cli/renders.py b/src/latch_sdk/cli/renders.py index 347b20f..b3a24c6 100644 --- a/src/latch_sdk/cli/renders.py +++ b/src/latch_sdk/cli/renders.py @@ -31,6 +31,8 @@ Instance, Operation, Status, + SubscriptionUsage, + UserSubscription, ) @@ -219,3 +221,32 @@ def render_application(app: Application, indent=""): for op in app.operations: render_operation(op, indent=indent + " |") click.echo(indent + " |" + "-" * 50) + + +def render_usage(title: str, usage: SubscriptionUsage, indent=""): + color = "green" + limit = "unlimited" + if usage.limit is not None: + limit = f"limit: {usage.limit}" + + if usage.limit == 0 or usage.limit - usage.in_use <= 0: + color = "red" + elif usage.in_use > 0 and usage.in_use / usage.limit >= 0.5: + color = "bright_yellow" + + click.echo(indent, nl=False) + click.echo(f"{title}: ", nl=False) + click.secho(f"{usage.in_use} ({limit})", bg=color) + + +def render_subscription(subscription: UserSubscription, indent=""): + click.echo(f"{indent}ID: {subscription.subscription_id}") + + render_usage("Applications", subscription.applications, indent=indent) + + if len(subscription.operations): + click.echo("Operations:") + for op, usage in subscription.operations.items(): + render_usage(op, usage, indent=indent + " " * 4) + + render_usage("Users", subscription.users, indent=indent) diff --git a/src/latch_sdk/exceptions.py b/src/latch_sdk/exceptions.py index 5674f19..bec6023 100644 --- a/src/latch_sdk/exceptions.py +++ b/src/latch_sdk/exceptions.py @@ -18,6 +18,8 @@ Latch exceptions. """ +from typing import Optional + from .models import Status _errors: dict[int, type["BaseLatchException"]] = {} @@ -52,11 +54,11 @@ class LatchWarning(BaseLatchException): class PairingWarning(LatchWarning): - account_id: "str | None" = None + account_id: Optional[str] = None class StatusWarning(LatchWarning): - status: "Status | None" = None + status: Optional[Status] = None class OpenGatewayWarning(PairingWarning, StatusWarning): diff --git a/src/latch_sdk/models.py b/src/latch_sdk/models.py index 5baaf6d..c29345c 100644 --- a/src/latch_sdk/models.py +++ b/src/latch_sdk/models.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING, Any, Iterable, TypedDict +from typing import TYPE_CHECKING, Any, Iterable, Optional, TypedDict if TYPE_CHECKING: # pragma: no cover from typing import Self @@ -58,7 +58,7 @@ class Status: status: bool #: Two factor data if it is required. - two_factor: "TwoFactor | None" = None + two_factor: Optional[TwoFactor] = None #: List of descendant operations. operations: "Iterable[Status] | None" = None @@ -130,7 +130,7 @@ class Operation: name: str #: Parent identifier - parent_id: "str | None" = None + parent_id: Optional[str] = None #: State of `Two factor` feature. two_factor: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED @@ -139,7 +139,7 @@ class Operation: lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED #: False means the latch is close and any action must be blocked. - status: "bool | None" = None + status: Optional[bool] = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @@ -225,10 +225,10 @@ class HistoryEntry: what: str #: What was the value before action. - was: "Any | None" + was: Optional[Any] #: What is the new value. - value: "Any | None" + value: Optional[Any] #: Event name name: str @@ -303,10 +303,10 @@ class Application: autoclose: int #: Contact email. - contact_mail: "str | None" = None + contact_mail: Optional[str] = None #: Contact phone number. - contact_phone: "str | None" = None + contact_phone: Optional[str] = None #: State of `Two factor` feature. two_factor: ExtraFeature = ExtraFeature.DISABLED @@ -386,10 +386,10 @@ class ApplicationHistory: autoclose: int #: Contact email. - contact_mail: "str | None" = None + contact_mail: Optional[str] = None #: Contact phone number. - contact_phone: "str | None" = None + contact_phone: Optional[str] = None #: State of `Two factor` feature. two_factor: ExtraFeature = ExtraFeature.DISABLED @@ -474,7 +474,7 @@ class HistoryResponse: history: Iterable[HistoryEntry] #: Last time user has been seen. - last_seen: "datetime | None" = None + last_seen: Optional[datetime] = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @@ -632,7 +632,7 @@ class Response: data: "dict[str, Any] | None" = None #: Response error data - error: "Error | None" = None + error: Optional[Error] = None @classmethod def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": @@ -657,3 +657,66 @@ def raise_on_error(self) -> None: return raise BaseLatchException(self.error.code, self.error.message) + + +@dataclass(frozen=True) +class SubscriptionUsage: + """ + Subscription feature usage. + """ + + #: How many items in use. + in_use: int + + #: Items limit for feature. :obj:`None` means infinite. + limit: Optional[int] = None + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + + data.update(kwargs) + + return cls( + in_use=data["inUse"], + limit=data["limit"] if data.get("limit", -1) >= 0 else None, + ) + + +@dataclass(frozen=True) +class UserSubscription: + """ + User subcription details. + """ + + #: Subcription identifier. + subscription_id: str + + #: Application registered. + applications: SubscriptionUsage + + #: Operations registered. + operations: dict[str, SubscriptionUsage] + + #: Users registered. + users: SubscriptionUsage + + @classmethod + def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self": + """ + Builds model from dict + """ + + data.update(kwargs) + + return cls( + subscription_id=data["id"], + applications=SubscriptionUsage.build_from_dict(data["applications"]), + operations={ + k: SubscriptionUsage.build_from_dict(v) + for k, v in data.get("operations", {}).items() + }, + users=SubscriptionUsage.build_from_dict(data["users"]), + ) diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index ea67285..0013623 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -29,6 +29,7 @@ Generic, Iterable, Literal, + Optional, TypedDict, TypeVar, cast, @@ -50,6 +51,7 @@ Operation, Response, Status, + UserSubscription, ) try: @@ -69,29 +71,70 @@ ) +#: Response type variable. It will be :class:`~latch_sdk.models.Response` on SyncIO classes and +#: :class:`~typing.Awaitable` [:class:`~latch_sdk.models.Response`] on AsyncIO classes. TResponse = TypeVar("TResponse", Awaitable[Response], Response) + +#: Return type for factories. TReturnType = TypeVar("TReturnType") + +#: Parameters specification for methods. P = ParamSpec("P") class Paths(TypedDict): + """ + Dictionary of versioned path to endpoint. + """ + + #: Endpoint to check account/operation/instance status. account_status: str + + #: Endpoint to pair a latch. account_pair: str + + #: Endpoint to pair a latch using user identifier. account_pair_with_id: str + + #: Endpoint to unpair a latch. account_unpair: str + + #: Endpoint to lock an account. account_lock: str + + #: Endpoint to unlock an account. account_unlock: str + + #: Endpoint to retrieve account history. account_history: str + + #: Endpoint to change account metadata. account_metadata: str + + #: Endpoint to manage operations. operation: str + + #: Endpoint to manage subscription. subscription: str + + #: Endpoint to manage applications. application: str + + #: Endpoint to manage instances. instance: str + + #: Endpoint to manage TOTPs. totp: str + + #: Endpoint to check Authorization control status. authorization_control_status: str class LatchSansIO(ABC, Generic[TResponse]): + """ + Latch core sans-io. + """ + API_PATH_CHECK_STATUS_PATTERN = "/api/${version}/status" API_PATH_PAIR_PATTERN = "/api/${version}/pair" API_PATH_PAIR_WITH_ID_PATTERN = "/api/${version}/pairWithId" @@ -126,9 +169,19 @@ def __init__( host: str = "latch.tu.com", port: int = 443, is_https: bool = True, - proxy_host: "str | None" = None, - proxy_port: "int | None" = None, - ) -> None: + proxy_host: Optional[str] = None, + proxy_port: Optional[int] = None, + ): + """ + :param app_id: Application identifier. + :param secret_key: Application secret key. + :param api_version: Version of API to use. + :param host: Host name for Latch service. + :param port: Port number for Latch service. + :param is_https: Whether to use TLS layer or not. + :param proxy_host: Proxy host name. + :param proxy_port: Proxy port number. + """ self.app_id = app_id self.secret_key = secret_key @@ -147,6 +200,9 @@ def __init__( @property def host(self) -> str: + """ + Host name for Latch service. + """ return self._host @host.setter @@ -181,6 +237,9 @@ def host(self, value: str): @property def port(self) -> int: + """ + Port number for Latch service. + """ return self._port @port.setter @@ -193,6 +252,9 @@ def port(self, value: int): @property def is_https(self) -> bool: + """ + Whether to use TLS layer or not. + """ return self._is_https @is_https.setter @@ -204,11 +266,14 @@ def is_https(self, value: bool): self._reconfigure_session() @property - def proxy_host(self) -> "str | None": + def proxy_host(self) -> Optional[str]: + """ + Proxy host name. + """ return self._proxy_host @proxy_host.setter - def proxy_host(self, value: "str | None"): + def proxy_host(self, value: Optional[str]): if value and ":" in value: value, port = value.split(":", 1) self.proxy_port = int(port) @@ -220,11 +285,14 @@ def proxy_host(self, value: "str | None"): self._reconfigure_session() @property - def proxy_port(self) -> "int | None": + def proxy_port(self) -> Optional[int]: + """ + Proxy port number. + """ return self._proxy_port @proxy_port.setter - def proxy_port(self, value: "int | None"): + def proxy_port(self, value: Optional[int]): if self._proxy_port == value: return @@ -233,6 +301,11 @@ def proxy_port(self, value: "int | None"): @classmethod def build_paths(cls, api_version: str) -> Paths: + """ + Build endpoints dictionary for a given version + + :param api_version: Version to get endpoints. + """ return { "account_status": Template( cls.API_PATH_CHECK_STATUS_PATTERN @@ -342,7 +415,7 @@ def authentication_headers( path_and_query: str, *, headers: "Mapping[str, str] | None" = None, - dt: "datetime | None" = None, + dt: Optional[datetime] = None, params: "Mapping[str, str] | None" = None, ) -> dict[str, str]: """ @@ -416,7 +489,7 @@ def get_serialized_headers(cls, headers: "Mapping[str, str] | None") -> str: Prepares and returns a string ready to be signed from the 11-paths specific HTTP headers received :param headers: a non neccesarily ordered map (array without duplicates) of the HTTP headers to be ordered. - :return: string The serialized headers, an empty string if no headers are passed, or None if there's a problem + :return: The serialized headers, an empty string if no headers are passed, or None if there's a problem such as non 11paths specific headers """ if not headers: @@ -453,7 +526,7 @@ def get_serialized_params(cls, params: Mapping[str, str]) -> str: def _reconfigure_session(self): # pragma: no cover pass - def build_url(self, path: str, *, query: "str | None" = None) -> str: + def build_url(self, path: str, *, query: Optional[str] = None) -> str: """ Build URL from path and query string. @@ -509,10 +582,10 @@ def _http( def _prepare_account_pair_params( self, *, - web3_account: "str | None" = None, - web3_signature: "str | None" = None, - common_name: "str | None" = None, - phone_number: "str | None" = None, + web3_account: Optional[str] = None, + web3_signature: Optional[str] = None, + common_name: Optional[str] = None, + phone_number: Optional[str] = None, ) -> dict[str, str]: params: dict[str, str] = {} @@ -533,10 +606,10 @@ def account_pair_with_id( self, account_id: str, *, - web3_account: "str | None" = None, - web3_signature: "str | None" = None, - common_name: "str | None" = None, - phone_number: "str | None" = None, + web3_account: Optional[str] = None, + web3_signature: Optional[str] = None, + common_name: Optional[str] = None, + phone_number: Optional[str] = None, ) -> TResponse: """ Pairs the origin provider with a user account (mail). @@ -569,10 +642,10 @@ def account_pair( self, token: str, *, - web3_account: "str | None" = None, - web3_signature: "str | None" = None, - common_name: "str | None" = None, - phone_number: "str | None" = None, + web3_account: Optional[str] = None, + web3_signature: Optional[str] = None, + common_name: Optional[str] = None, + phone_number: Optional[str] = None, ) -> TResponse: """ Pairs the token provider with a user account. @@ -605,14 +678,14 @@ def account_status( self, account_id: str, *, - instance_id: "str | None" = None, - operation_id: "str | None" = None, + instance_id: Optional[str] = None, + operation_id: Optional[str] = None, silent: bool = False, nootp: bool = False, - otp_code: "str | None" = None, - otp_message: "str | None" = None, - phone_number: "str | None" = None, - location: "str | None" = None, + otp_code: Optional[str] = None, + otp_message: Optional[str] = None, + phone_number: Optional[str] = None, + location: Optional[str] = None, ) -> TResponse: """ Return operation status for a given accountId and operation while @@ -666,8 +739,8 @@ def account_set_metadata( self, account_id: str, *, - common_name: "str | None" = None, - phone_number: "str | None" = None, + common_name: Optional[str] = None, + phone_number: Optional[str] = None, ) -> TResponse: """ Set account metadata. @@ -705,8 +778,8 @@ def account_lock( self, account_id: str, *, - instance_id: "str | None" = None, - operation_id: "str | None" = None, + instance_id: Optional[str] = None, + operation_id: Optional[str] = None, ) -> TResponse: """ Locks the operation. @@ -729,8 +802,8 @@ def account_unlock( self, account_id: str, *, - instance_id: "str | None" = None, - operation_id: "str | None" = None, + instance_id: Optional[str] = None, + operation_id: Optional[str] = None, ): """ Unlocks the operation @@ -753,11 +826,11 @@ def account_history( self, account_id: str, *, - from_dt: "datetime | None" = None, - to_dt: "datetime | None" = None, + from_dt: Optional[datetime] = None, + to_dt: Optional[datetime] = None, ): """ - Get history status + Get history account. :param account_id: The account identifier. :param from_dt: Datetime to start from. @@ -782,7 +855,7 @@ def authorization_control_status( self, control_id: str, *, - location: "str | None" = None, + location: Optional[str] = None, send_notifications: bool = True, ): """ @@ -837,9 +910,9 @@ def operation_update( self, operation_id: str, *, - name: "str | None" = None, - two_factor: "ExtraFeature | None" = None, - lock_on_request: "ExtraFeature | None" = None, + name: Optional[str] = None, + two_factor: Optional[ExtraFeature] = None, + lock_on_request: Optional[ExtraFeature] = None, ): """ Update an operation. @@ -876,7 +949,7 @@ def operation_remove(self, operation_id: str): "DELETE", "/".join((self._paths["operation"], operation_id)) ) - def operation_list(self, *, parent_id: "str | None" = None): + def operation_list(self, *, parent_id: Optional[str] = None): """ Get a list of operations. @@ -889,7 +962,7 @@ def operation_list(self, *, parent_id: "str | None" = None): return self._prepare_http("GET", "/".join(parts)) - def instance_list(self, account_id: str, *, operation_id: "str | None" = None): + def instance_list(self, account_id: str, *, operation_id: Optional[str] = None): """ Get a list of instances. @@ -904,7 +977,7 @@ def instance_list(self, account_id: str, *, operation_id: "str | None" = None): return self._prepare_http("GET", "/".join(parts)) def instance_create( - self, account_id: str, name: str, *, operation_id: "str | None" = None + self, account_id: str, name: str, *, operation_id: Optional[str] = None ): """ Create an instance. @@ -932,10 +1005,10 @@ def instance_update( account_id: str, instance_id: str, *, - operation_id: "str | None" = None, - name: "str | None" = None, - two_factor: "ExtraFeature | None" = None, - lock_on_request: "ExtraFeature | None" = None, + operation_id: Optional[str] = None, + name: Optional[str] = None, + two_factor: Optional[ExtraFeature] = None, + lock_on_request: Optional[ExtraFeature] = None, ): """ Update an instance. @@ -976,7 +1049,7 @@ def instance_update( ) def instance_remove( - self, account_id: str, instance_id: str, operation_id: "str | None" = None + self, account_id: str, instance_id: str, operation_id: Optional[str] = None ): """ Remove the instance. @@ -1067,6 +1140,12 @@ def application_create( }, ) + def subscription_load(self): + """ + Returns the developer's subscription. + """ + return self._prepare_http("GET", self._paths["subscription"]) + def application_list(self): """ Returns the list of registered applications. @@ -1077,11 +1156,11 @@ def application_update( self, application_id: str, *, - name: "str | None" = None, - two_factor: "ExtraFeature | None" = None, - lock_on_request: "ExtraFeature | None" = None, - contact_phone: "str | None" = None, - contact_mail: "str | None" = None, + name: Optional[str] = None, + two_factor: Optional[ExtraFeature] = None, + lock_on_request: Optional[ExtraFeature] = None, + contact_phone: Optional[str] = None, + contact_mail: Optional[str] = None, ): """ Modify a given application @@ -1131,17 +1210,30 @@ def application_remove(self, application_id: str): ) +#: Factory type alias. TFactory: "TypeAlias" = Callable[[Response], "TReturnType"] class LatchSDKSansIO(Generic[TResponse]): def __init__(self, core: LatchSansIO[TResponse]): + """ + Latch SDK sans-io. + + :param core: Latch core driver. + """ self._core: LatchSansIO[TResponse] = core + @property + def core(self) -> LatchSansIO[TResponse]: # pragma: no cover + """ + Latch core driver. + """ + return self._core + def response_no_error(resp: Response) -> Literal[True]: """ - :return: `True` if not error. + :return: `True` if no error. """ resp.raise_on_error() @@ -1274,6 +1366,17 @@ def response_totp(resp: Response) -> TOTP: return TOTP.build_from_dict(resp.data) +def response_subscription(resp: Response) -> UserSubscription: + """ + :return: Developer's subscription. + """ + resp.raise_on_error() + + assert resp.data is not None, "No error or data" + + return UserSubscription.build_from_dict(resp.data["subscription"]) + + def response_application_create(resp: Response) -> ApplicationCreateResponse: """ :return: Application creation information. diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py index 5870950..3831c36 100644 --- a/src/latch_sdk/syncio/base.py +++ b/src/latch_sdk/syncio/base.py @@ -38,6 +38,7 @@ response_no_error, response_operation, response_operation_list, + response_subscription, response_totp, ) from ..utils import wraps_and_replace_return @@ -110,6 +111,8 @@ class LatchSDK(LatchSDKSansIO[Response]): response_authorization_control_status, BaseLatch.authorization_control_status ) + subscription_load = wrap_method(response_subscription, BaseLatch.subscription_load) + application_list = wrap_method( response_application_list, BaseLatch.application_list ) diff --git a/src/latch_sdk/syncio/httpx.py b/src/latch_sdk/syncio/httpx.py index a1c6c04..6cf5971 100644 --- a/src/latch_sdk/syncio/httpx.py +++ b/src/latch_sdk/syncio/httpx.py @@ -35,6 +35,8 @@ ) raise +from typing import Optional + from .base import BaseLatch from ..models import Response @@ -52,7 +54,7 @@ class Latch(BaseLatch): _session: Client def _reconfigure_session(self) -> None: - proxy: "str | None" = None + proxy: Optional[str] = None if self.proxy_host: proxy = f"http://{self.proxy_host}" if self.proxy_port: diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py index 6a702e5..3662534 100644 --- a/src/latch_sdk/utils.py +++ b/src/latch_sdk/utils.py @@ -19,7 +19,7 @@ """ from functools import wraps -from typing import TYPE_CHECKING, Any, Callable, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar try: from typing import ParamSpec @@ -35,7 +35,7 @@ def sign_data( data: bytes, ) -> bytes: """ - Signs data using a secret + Signs data using a secret. :param secret: Secret to use to sign `data`. :param data: Data to sign. @@ -58,7 +58,7 @@ def wraps_and_replace_return( meth: "Callable[Concatenate[Any, P], Any]", return_type: T, *, - factory_docs: "str | None" = None, + factory_docs: Optional[str] = None, ) -> ( "Callable[[Callable[Concatenate[TSelf, P], T]], Callable[Concatenate[TSelf, P], T]]" ): diff --git a/tests/factory.py b/tests/factory.py index b90ca72..68b58e5 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -229,6 +229,22 @@ def totp_validate_error(cls): def authorization_control_status(cls, status: bool = True): return {"data": {"status": "on" if status else "off"}} + @classmethod + def subscription_load(cls, *, limit: int = -1): + return { + "data": { + "subscription": { + "id": "vip", + "applications": {"inUse": 2, "limit": limit}, + "operations": { + "Latch OIDC (develop)": {"inUse": 3, "limit": limit}, + "Latch OIDC (staging)": {"inUse": 0, "limit": limit}, + }, + "users": {"inUse": 2, "limit": limit}, + } + } + } + @classmethod def application_create(cls, *, application_id: str = "aaaaaaaaaaaphhhhhhh"): return { diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 92f254a..f1a86da 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -19,7 +19,7 @@ from datetime import datetime, timedelta, timezone from string import Template from textwrap import dedent -from typing import Callable, Mapping +from typing import Callable, Mapping, Optional from unittest import TestCase from unittest.mock import Mock, patch @@ -45,6 +45,7 @@ ) from latch_sdk.sansio import ( LatchSansIO, + UserSubscription, response_account_history, response_account_pair, response_account_status, @@ -56,6 +57,7 @@ response_no_error, response_operation, response_operation_list, + response_subscription, response_totp, ) from latch_sdk.utils import sign_data @@ -343,6 +345,43 @@ def test_status_close(self) -> None: self.assertFalse(response_authorization_control_status(resp)) +class ResponseSubscriptionTestCase(TestCase): + def test_unlimited(self) -> None: + resp = Response.build_from_dict(ResponseFactory.subscription_load()) + + data = response_subscription(resp) + + self.assertIsInstance(data, UserSubscription) + self.assertIsNotNone(data.subscription_id) + self.assertIsNotNone(data.applications.in_use) + self.assertIsNone(data.applications.limit) + + for op, usage in data.operations.items(): + self.assertIsNotNone(usage.in_use) + self.assertIsNone(usage.limit) + + self.assertIsNotNone(data.users.in_use) + self.assertIsNone(data.users.limit) + + def test_limited(self) -> None: + limit = 4 + resp = Response.build_from_dict(ResponseFactory.subscription_load(limit=limit)) + + data = response_subscription(resp) + + self.assertIsInstance(data, UserSubscription) + self.assertIsNotNone(data.subscription_id) + self.assertIsNotNone(data.applications.in_use) + self.assertEqual(data.applications.limit, limit) + + for op, usage in data.operations.items(): + self.assertIsNotNone(usage.in_use) + self.assertEqual(usage.limit, limit) + + self.assertIsNotNone(data.users.in_use) + self.assertEqual(data.users.limit, limit) + + class ResponseApplicationCreateTestCase(TestCase): def test_no_error(self) -> None: resp = Response.build_from_dict(ResponseFactory.application_create()) @@ -1053,8 +1092,8 @@ def assert_account_status_response( ) # type: ignore def test_account_status( self, - operation_id: "str | None", - instance_id: "str | None", + operation_id: Optional[str], + instance_id: Optional[str], nootp: bool, silent: bool, expected_path: str, @@ -1217,7 +1256,10 @@ def _http_cb( ], ) # type: ignore def test_account_lock( - self, operation_id: "str | None", instance_id: "str | None", expected_path: str + self, + operation_id: Optional[str], + instance_id: Optional[str], + expected_path: str, ) -> None: account_id = "eregerdscvrtrd" @@ -1282,7 +1324,10 @@ def _http_cb( ], ) # type: ignore def test_account_unlock( - self, operation_id: "str | None", instance_id: "str | None", expected_path: str + self, + operation_id: Optional[str], + instance_id: Optional[str], + expected_path: str, ) -> None: account_id = "eregerdscvrtrd" @@ -2390,7 +2435,7 @@ def _http_cb( ], ) # type: ignore def test_authorization_control_status( - self, send_notifications: bool, location: "str | None", query: str + self, send_notifications: bool, location: Optional[str], query: str ): control_id = "rtjgnbvc345tyhbdvstr" @@ -2453,6 +2498,34 @@ def _http_cb( http_cb.assert_called_once() + def test_subscription_load(self): + def _http_cb( + method: str, + path: str, + headers: Mapping[str, str], + params: "Mapping[str, str] | None" = None, + ) -> Response: + self.assertEqual(method, "GET") + + self.assertEqual( + path, + "/api/3.0/subscription", + ) + self.assert_request(method, path, headers, params) + + self.assertIsNone(params) + + return Response.build_from_dict(ResponseFactory.subscription_load()) + + http_cb = Mock(side_effect=_http_cb) + + latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb) + + resp = latch.subscription_load() + self.assertIsInstance(resp, Response) + + http_cb.assert_called_once() + def test_application_create(self) -> None: name = "test_app" contact_mail = "example@tu.com" From 2c9a85525537fa4a56abd7c7e9927ee613f34000 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 16:34:20 +0100 Subject: [PATCH 22/25] Fix docs --- src/latch_sdk/sansio.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py index 0013623..faafcdf 100644 --- a/src/latch_sdk/sansio.py +++ b/src/latch_sdk/sansio.py @@ -1215,10 +1215,12 @@ def application_remove(self, application_id: str): class LatchSDKSansIO(Generic[TResponse]): + """ + Latch SDK sans-io. + """ + def __init__(self, core: LatchSansIO[TResponse]): """ - Latch SDK sans-io. - :param core: Latch core driver. """ self._core: LatchSansIO[TResponse] = core From 07fb2e4be405381481e4db6e16d310ca40ea7a0b Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 17:53:54 +0100 Subject: [PATCH 23/25] More docs and fixes --- Python.mk | 14 +++++++--- README.md | 11 ++++---- Version.mk | 2 +- docs/source/branching.rst | 44 +++++++++++++++++++++++++++++++ docs/source/developing.rst | 54 ++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 4 ++- 6 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 docs/source/branching.rst create mode 100644 docs/source/developing.rst diff --git a/Python.mk b/Python.mk index 07cfbb2..074e497 100644 --- a/Python.mk +++ b/Python.mk @@ -5,12 +5,18 @@ PACKAGE_COVERAGE=$(PACKAGE_DIR) ISORT_PARAMS?= # Minimum coverage -COVER_MIN_PERCENTAGE=50 +COVER_MIN_PERCENTAGE=95 -PYPI_REPO?=artifactory-hi +PYPI_REPO?= PYPI_REPO_USERNAME?= PYPI_REPO_PASSWORD?= +ifneq (${PYPI_REPO},) +_PYPI_PUBLISH_ARGS=--repository=${PYPI_REPO} +else +_PYPI_PUBLISH_ARGS= +endif + POETRY_EXECUTABLE?=poetry POETRY_RUN?=${POETRY_EXECUTABLE} run @@ -80,5 +86,5 @@ pull-request: lint tests build: ${POETRY_EXECUTABLE} build -publish: build - ${POETRY_EXECUTABLE} publish --repository=${PYPI_REPO} --username="${PYPI_REPO_USERNAME}" --password="${PYPI_REPO_PASSWORD}" +publish: #build + ${POETRY_EXECUTABLE} publish ${_PYPI_PUBLISH_ARGS} --username="${PYPI_REPO_USERNAME}" --password="${PYPI_REPO_PASSWORD}" diff --git a/README.md b/README.md index 172f3b4..b3efa96 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -### LATCH PYTHON SDK ### +# LATCH PYTHON SDK - -#### PREREQUISITES #### +## Prerequisites * Python. @@ -10,7 +9,7 @@ * To get the "Application ID" and "Secret", (fundamental values for integrating Latch in any application), it’s necessary to register a developer account in Latch's website: https://latch.tu.com. On the upper right side, click on "Developer area" -#### USING THE SDK IN PYTHON #### +## Using the SDK * Install "latch-sdk-telefonica" @@ -45,7 +44,7 @@ ``` -## USING PYTHON SDK FOR WEB3 SERVICES ## +## Using the SDK for web3 contracts For using the Python SDK within an Web3 service, you must complain with the following: @@ -54,7 +53,7 @@ For using the Python SDK within an Web3 service, you must complain with the foll * You need a wallet to operate on Polygon. You can easily create one through Metamask. -### Creation of a WEB3 Latch app ### +### Creation of a WEB3 Latch app Once you have your developer Latch account created, you must logging in the website. diff --git a/Version.mk b/Version.mk index e0a2867..9ba690b 100644 --- a/Version.mk +++ b/Version.mk @@ -1,7 +1,7 @@ version: - @poetry version + @poetry version --short version-set.%: @poetry version $* diff --git a/docs/source/branching.rst b/docs/source/branching.rst new file mode 100644 index 0000000..0a81b08 --- /dev/null +++ b/docs/source/branching.rst @@ -0,0 +1,44 @@ +=========================== +Release and branching model +=========================== + +The default branch is `main`. There are two branches types protected against pushing commits directly: +`main` and `release/X.Y` branches. Any new code pushed to protected branches must be done by pull requests. + +The library version is defined on `pyproject.toml` at section `project` and key `version`. It uses +`PEP440 `_ format and `Semver 2.0.0 meaning `_. +It must contain the **next stable version** to release. In the case of `main` branch, the `patch` part +must be `0`. Because the `patch` part only increase when there is a bug fix in already released version. +On the other hand, in `release/1.7` branch version could be `1.7.1` because the version `1.7.0` has been +released previously. + +Each time new code is pushed to `main` branch (by pull request) it will create a beta release with current code. For example, if +we are developing the future release `1.7.0` it will create a beta release like `1.7.0b4`. + +When the code is considered stable and it is going to be released. We must create a branch `release/1.7` from `main`. +Automatically it will create a new pull request to `main` bumping verision to `1.8.0`. Any new code pushed to `release/X.Y` +branches will create a release candidate release like `1.7.0rc3`. When a release candidate version is considered +ready to release, a manual action will be lauched by maintainers in order to create the final release `1.7.0`. +It will create automatically a pull request to `release/1.7` bumping version to `1.7.1`. + +It is posible to generate a release from any branch. For any branch, except regular ones, it will create an alpha version. + ++-------------+-------------------+------------------------------------------------------------------------+ +| Branch | Version level | Action on push | ++=============+===================+========================================================================+ +| main | beta | Create release | ++-------------+-------------------+------------------------------------------------------------------------+ +| release/X.Y | release candidate | Create release and bump minor version part on develop branch if needed | ++-------------+-------------------+------------------------------------------------------------------------+ +| any | alpha | None | ++-------------+-------------------+------------------------------------------------------------------------+ + + +.. important:: + NEVER CHANGE THE VERSION MANUALLY + + There is an action to bump version on develop if you want to increase the `major` part. + + +Any new release created will be build and published on `PyPi `_. +Even pre-release versions. So, anybody could donwload an alpha or beta version under their own risk. \ No newline at end of file diff --git a/docs/source/developing.rst b/docs/source/developing.rst new file mode 100644 index 0000000..0e20263 --- /dev/null +++ b/docs/source/developing.rst @@ -0,0 +1,54 @@ +============== +Developing SDK +============== + +------------ +Requirements +------------ + +* Python `>=3.9` +* Poetry `>=2.0.0` + +------------------------- +Setting up the repository +------------------------- + +The first step is to generate a virtual environment with `poetry `_: + +.. code-block:: bash + + $ poetry install --all-extras + +It will create a venv for your default python version and install all requirements and extras. + +---------- +Code style +---------- + +We use `ruff `_ as code style formatter and checker and `isort `_ +and `absolufy-imports `_ (deprecated, looking for alternative) +to format and check imports. + +In order to check your code you can run: + +.. code-block:: bash + + $ make lint + +But, if you want to ensure your code is following rules you can reformat it using: + +.. code-block:: bash + + $ make beautify + +------- +Testing +------- + +All tests must be in `tests/` directory. We use `pytest `_ as test runner. + +You could run all tests and coverage analysis using: + +.. code-block:: bash + + $ make tests \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 2966bde..4abf615 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,4 +13,6 @@ Latch SDK for Python documentation getting-started api/index - cli \ No newline at end of file + cli + branching + developing \ No newline at end of file From a7cef2fb68b1e8190e23c8255a5471ef0473b019 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 5 Mar 2025 17:57:07 +0100 Subject: [PATCH 24/25] Typo --- docs/source/branching.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/branching.rst b/docs/source/branching.rst index 0a81b08..f3c52c8 100644 --- a/docs/source/branching.rst +++ b/docs/source/branching.rst @@ -40,5 +40,5 @@ It is posible to generate a release from any branch. For any branch, except regu There is an action to bump version on develop if you want to increase the `major` part. -Any new release created will be build and published on `PyPi `_. +Any new release created will be built and published on `PyPi `_. Even pre-release versions. So, anybody could donwload an alpha or beta version under their own risk. \ No newline at end of file From 0ab67953495bd6d1a6e2b7fd832f0b4a4347a163 Mon Sep 17 00:00:00 2001 From: Alfred Date: Wed, 12 Mar 2025 15:51:14 +0100 Subject: [PATCH 25/25] Fixes --- docs/source/getting-started.rst | 4 ++-- src/latch_sdk/asyncio/base.py | 2 +- tests/asyncio/test_base.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index 7f2c038..17bd41d 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -40,7 +40,7 @@ aiohttp ======= This group provides the requirements to use `aiohttp `_ -library as http backend. It allows you to use :class:`latch_sdk.syncio.aiohttp.Latch` core class. +library as http backend. It allows you to use :class:`latch_sdk.asyncio.aiohttp.Latch` core class. .. code-block:: bash @@ -109,7 +109,7 @@ must used to instance the :class:`~latch_sdk.asyncio.LatchSDK`. from latch_sdk.asyncio import LatchSDK from latch_sdk.asyncio.aiohttp import Latch - async main(): + async def main(): latch = LatchSDK(Latch(app_id=MY_APP_ID, secret=MY_SECRET)) status = await latch.status(MY_ACCOUNT_ID) diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py index b95eb5f..6123e22 100644 --- a/src/latch_sdk/asyncio/base.py +++ b/src/latch_sdk/asyncio/base.py @@ -63,7 +63,7 @@ def wrap_method( meth, factory.__annotations__["return"], factory_docs=factory.__doc__ ) async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType: - return factory(getattr(self._core, meth.__name__)(*args, **kwargs)) + return factory(await getattr(self._core, meth.__name__)(*args, **kwargs)) return wrapper diff --git a/tests/asyncio/test_base.py b/tests/asyncio/test_base.py index 407769d..9348f57 100644 --- a/tests/asyncio/test_base.py +++ b/tests/asyncio/test_base.py @@ -18,7 +18,7 @@ from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock -from latch_sdk.asyncio.base import BaseLatch, LatchSDK +from latch_sdk.asyncio.base import LatchSDK from latch_sdk.exceptions import ApplicationAlreadyPaired, InvalidTOTP, TokenNotFound from latch_sdk.models import Response @@ -30,7 +30,7 @@ class LatchSDKTestCase(IsolatedAsyncioTestCase): SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf" def setUp(self) -> None: - self.core = AsyncMock(BaseLatch) + self.core = AsyncMock() self.latch_sdk = LatchSDK(self.core) return super().setUp()