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..e2e46ce
--- /dev/null
+++ b/.github/workflows/common_get_version.yaml
@@ -0,0 +1,93 @@
+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: ubuntu-latest
+
+ 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..f8a2e89
--- /dev/null
+++ b/.github/workflows/common_make_release.yaml
@@ -0,0 +1,52 @@
+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: ubuntu-latest
+
+ 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 --username=__token__ --password=${{ secrets.PYPI_API_TOKEN }}
+
+ - 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..b54ff23
--- /dev/null
+++ b/.github/workflows/common_push_version.yaml
@@ -0,0 +1,56 @@
+# 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: ubuntu-latest
+
+ 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_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
new file mode 100644
index 0000000..4331c1d
--- /dev/null
+++ b/.github/workflows/latch_sdk_python_pull_request.yaml
@@ -0,0 +1,63 @@
+# 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: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["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: ubuntu-latest
+ 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
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..454c72f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,14 @@ latch.pyc
.env
dist
.python-version
-src/
.DS_Store
+# Coverage
+.coverage
+coverage.xml
+
+# VSCode
+.vscode
+
+# Sphinx
+docs/build
\ 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..074e497
--- /dev/null
+++ b/Python.mk
@@ -0,0 +1,90 @@
+# Author: Alfred
+
+PACKAGE_COVERAGE=$(PACKAGE_DIR)
+
+ISORT_PARAMS?=
+
+# Minimum coverage
+COVER_MIN_PERCENTAGE=95
+
+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
+
+
+# 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 --all-extras --without=docs
+
+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 ${_PYPI_PUBLISH_ARGS} --username="${PYPI_REPO_USERNAME}" --password="${PYPI_REPO_PASSWORD}"
diff --git a/README.md b/README.md
index 2c1dec8..b3efa96 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,50 @@
-### LATCH PYTHON SDK ###
+# LATCH PYTHON SDK
-
-#### PREREQUISITES ####
+## Prerequisites
* 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 ####
+## Using the SDK
* Install "latch-sdk-telefonica"
-```
- pip install latch-sdk-telefonica
-```
-* Import "latch" module.
-```
- import 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.account_pair("PAIRING_CODE_HERE")
+ status = api.account_status(account_id)
+ assert api.account_unpair(account_id) is True
```
-## 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:
@@ -55,25 +53,20 @@ 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.
-[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"
* 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.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/Version.mk b/Version.mk
new file mode 100644
index 0000000..9ba690b
--- /dev/null
+++ b/Version.mk
@@ -0,0 +1,8 @@
+
+
+version:
+ @poetry version --short
+
+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/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..c73b18b
--- /dev/null
+++ b/docs/source/api/exceptions.rst
@@ -0,0 +1,108 @@
+==========
+Exceptions
+==========
+
+
+.. rubric:: **Module:** `latch_sdk.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/api/index.rst b/docs/source/api/index.rst
new file mode 100644
index 0000000..61881e5
--- /dev/null
+++ b/docs/source/api/index.rst
@@ -0,0 +1,15 @@
+=============
+API reference
+=============
+
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+ syncio
+ asyncio
+ models
+ exceptions
+ utils
+ sansio
\ 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..d9d05af
--- /dev/null
+++ b/docs/source/api/models.rst
@@ -0,0 +1,10 @@
+======
+Models
+======
+
+
+.. rubric:: **Module:** `latch_sdk.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
new file mode 100644
index 0000000..9d7b0fb
--- /dev/null
+++ b/docs/source/api/syncio.rst
@@ -0,0 +1,61 @@
+===============
+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/branching.rst b/docs/source/branching.rst
new file mode 100644
index 0000000..f3c52c8
--- /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 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
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
new file mode 100644
index 0000000..abc1c9d
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,92 @@
+# 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
+
+from datetime import date
+from importlib import metadata
+
+project = "Latch SDK for Python"
+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
+
+extensions = [
+ "sphinx.ext.autodoc",
+ # "sphinx_autodoc_typehints",
+ "sphinx_click",
+ "sphinx.ext.intersphinx",
+]
+
+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"]
+
+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 = "bysource"
+
+add_module_names = True
+
+set_type_checking_flag = True
+
+python_use_unqualified_type_names = True
+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",
+}
+
+
+# -- 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"
+
+
+# -- Latex ------------------------------
+
+latex_elements = {
+ "maxlistdepth": "9",
+}
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/getting-started.rst b/docs/source/getting-started.rst
new file mode 100644
index 0000000..17bd41d
--- /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.asyncio.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.aiohttp import Latch
+
+ async def 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
new file mode 100644
index 0000000..4abf615
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,18 @@
+.. 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:
+
+ getting-started
+ api/index
+ cli
+ branching
+ developing
\ No newline at end of file
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..c7371ac
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1723 @@
+# 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"]
+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"]
+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\""}
+
+[[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\""}
+
+[[package]]
+name = "click"
+version = "8.1.8"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+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\""}
+
+[[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 = "platform_system == \"Windows\" or 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"]
+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\""}
+
+[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"]
+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"]
+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"]
+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"},
+]
+
+[[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"
+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"]
+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\""}
+
+[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"]
+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"]
+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"]
+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 = "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"
+description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
+optional = false
+python-versions = ">=3.9"
+groups = ["docs"]
+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"]
+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"]
+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"]
+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"]
+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"]
+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\" or python_version < \"3.10\""}
+
+[[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\""}
+
+[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,<4.0.0"
+content-hash = "a0826f491c37edda96fb636f25470d2406dcd3ffa894f0589eb6bf7860d5e742"
diff --git a/pyproject.toml b/pyproject.toml
index 9847d33..56f0ebc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,27 +1,85 @@
[project]
name = "latch-sdk-telefonica"
-version = "2.0.2"
+version = "3.0.0"
authors = [
- { name="Telefonica Latch", email="soporte.latch@telefonica.com" },
+ { name="Telefonica Latch", email="soporte.latch@tu.com" },
]
-description = "latch sdk-pyhton"
+description = "Latch SDK for Pyhton"
readme = "README.md"
-requires-python = ">=3.7"
+requires-python = ">=3.9,<4.0.0"
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"
-]
+dependencies = ['typing-extensions (>=4.12.2,<5.0.0) ; python_version < "3.10"']
[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:cli"
+
+[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"
+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 = ["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/latch_sdk/__init__.py b/src/latch_sdk/__init__.py
new file mode 100644
index 0000000..cb1b655
--- /dev/null
+++ b/src/latch_sdk/__init__.py
@@ -0,0 +1,15 @@
+# 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
new file mode 100644
index 0000000..884579d
--- /dev/null
+++ b/src/latch_sdk/asyncio/__init__.py
@@ -0,0 +1,29 @@
+# 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
+"""
+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.
+
+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
+
+__all__ = ["LatchSDK"]
diff --git a/src/latch_sdk/asyncio/aiohttp.py b/src/latch_sdk/asyncio/aiohttp.py
new file mode 100644
index 0000000..c765a08
--- /dev/null
+++ b/src/latch_sdk/asyncio/aiohttp.py
@@ -0,0 +1,82 @@
+# 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 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:
+
+.. code-block:: bash
+
+ $ pip install latch-sdk-telefonica[aiohttp]
+
+.. 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
+
+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):
+ """
+ Latch core class using asynchronous aiohttp library.
+ """
+
+ _session: ClientSession
+
+ def _reconfigure_session(self) -> None:
+ proxy: Optional[str] = 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,
+ ) -> Response:
+ """
+ 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 Response.build_from_dict(
+ await response.json() if await response.text() else {}
+ )
diff --git a/src/latch_sdk/asyncio/base.py b/src/latch_sdk/asyncio/base.py
new file mode 100644
index 0000000..6123e22
--- /dev/null
+++ b/src/latch_sdk/asyncio/base.py
@@ -0,0 +1,123 @@
+# 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
+
+"""
+Base classes for asynchronous environments.
+"""
+
+from typing import TYPE_CHECKING, Any, Awaitable, Callable
+
+from ..models import Response
+from ..sansio import (
+ LatchSansIO,
+ LatchSDKSansIO,
+ P,
+ TFactory,
+ TReturnType,
+ response_account_history,
+ response_account_pair,
+ response_account_status,
+ response_application_create,
+ response_application_list,
+ response_authorization_control_status,
+ response_instance_create,
+ response_instance_list,
+ response_no_error,
+ response_operation,
+ response_operation_list,
+ response_subscription,
+ response_totp,
+)
+from ..utils import wraps_and_replace_return
+
+if TYPE_CHECKING: # pragma: no cover
+ from typing import Concatenate
+
+
+class BaseLatch(LatchSansIO[Awaitable[Response]]):
+ pass
+
+
+def wrap_method(
+ factory: "TFactory[TReturnType]",
+ 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"], factory_docs=factory.__doc__
+ )
+ async def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType:
+ return factory(await getattr(self._core, meth.__name__)(*args, **kwargs))
+
+ return wrapper
+
+
+class LatchSDK(LatchSDKSansIO[Awaitable[Response]]):
+ """
+ Latch SDK asynchronous main class.
+ """
+
+ account_pair = wrap_method(response_account_pair, BaseLatch.account_pair)
+ account_pair_with_id = wrap_method(
+ response_account_pair,
+ BaseLatch.account_pair_with_id,
+ )
+
+ account_unpair = wrap_method(response_no_error, BaseLatch.account_unpair)
+
+ account_status = wrap_method(response_account_status, BaseLatch.account_status)
+
+ account_lock = wrap_method(response_no_error, BaseLatch.account_lock)
+ account_unlock = wrap_method(response_no_error, BaseLatch.account_unlock)
+
+ 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)
+ 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_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)
+
+ authorization_control_status = wrap_method(
+ 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
+ )
+ 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
new file mode 100644
index 0000000..4cf6107
--- /dev/null
+++ b/src/latch_sdk/asyncio/httpx.py
@@ -0,0 +1,85 @@
+# 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 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:
+
+.. code-block:: bash
+
+ $ pip install latch-sdk-telefonica[httpx]
+
+.. warning:: If extra requirements are no satisfied an error will rise on module import.
+"""
+
+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 typing import Optional
+
+from .base import BaseLatch
+from ..models import Response
+
+try:
+ from collections.abc import Mapping
+except ImportError: # pragma: no cover
+ from typing import Mapping
+
+
+class Latch(BaseLatch):
+ """
+ Latch core class using asynchronous httpx library.
+ """
+
+ _session: AsyncClient
+
+ def _reconfigure_session(self) -> None:
+ proxy: Optional[str] = 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,
+ ) -> Response:
+ """
+ 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 Response.build_from_dict(response.json() if response.text else {})
diff --git a/src/latch_sdk/cli/__init__.py b/src/latch_sdk/cli/__init__.py
new file mode 100644
index 0000000..7e5edd9
--- /dev/null
+++ b/src/latch_sdk/cli/__init__.py
@@ -0,0 +1,79 @@
+# 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 .account import account
+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
+
+
+@click.group(name="LatchSDK")
+@click.version_option(package_name="latch-sdk-telefonica")
+@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 cli(ctx: click.Context, app_id: str, secret: str):
+ """
+ Latch command-line application
+ """
+
+ ctx.obj = LatchSDK(Latch(app_id, secret))
+
+
+cli.add_command(account)
+cli.add_command(operation)
+cli.add_command(totp)
+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
new file mode 100644
index 0000000..dd1e579
--- /dev/null
+++ b/src/latch_sdk/cli/account.py
@@ -0,0 +1,256 @@
+# 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
+from typing import Optional
+
+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")
+@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: Optional[str] = None,
+ web3_signature: Optional[str] = None,
+ common_name: Optional[str] = None,
+ phone_number: Optional[str] = None,
+):
+ """
+ Pair a new latch.
+ """
+ result = latch.account_pair(
+ token,
+ web3_account=web3_account,
+ web3_signature=web3_signature,
+ common_name=common_name,
+ phone_number=phone_number,
+ )
+
+ 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")
+@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: 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.
+ """
+ result = latch.account_pair_with_id(
+ account_id,
+ web3_account=web3_account,
+ web3_signature=web3_signature,
+ common_name=common_name,
+ phone_number=phone_number,
+ )
+
+ 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",
+)
+@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,
+ account_id: str,
+ operation_id: Optional[str],
+ instance_id: Optional[str],
+ nootp: bool,
+ silent: bool,
+ otp_code: Optional[str],
+ otp_message: Optional[str],
+ phone_number: Optional[str],
+ location: Optional[str],
+):
+ """
+ Get latch status
+ """
+ status = latch.account_status(
+ account_id,
+ operation_id=operation_id,
+ 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: Optional[str] = None,
+ phone_number: Optional[str] = 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(
+ "--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: Optional[str],
+ instance_id: Optional[str],
+):
+ """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: Optional[str],
+ instance_id: Optional[str],
+):
+ """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: Optional[datetime],
+ to_dt: Optional[datetime],
+):
+ """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
new file mode 100644
index 0000000..9263dc7
--- /dev/null
+++ b/src/latch_sdk/cli/application.py
@@ -0,0 +1,164 @@
+# 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 Optional
+
+import click
+
+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()
+def application():
+ """Application actions"""
+
+
+@application.command(name="create")
+@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",
+)
+@click.option(
+ "--contact-mail",
+ "-m",
+ type=str,
+ required=False,
+ help="Conctact email",
+)
+@click.option(
+ "--contact-phone",
+ "-p",
+ type=str,
+ required=False,
+ help="Conctact phone number",
+)
+@pass_latch_sdk
+def create(
+ latch: LatchSDK,
+ name: str,
+ two_factor: ExtraFeature,
+ lock_on_request: ExtraFeature,
+ contact_mail: Optional[str],
+ contact_phone: Optional[str],
+):
+ """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_application_create_response(application)
+
+
+@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(
+ "--contact-mail",
+ "-m",
+ type=str,
+ required=False,
+ help="Conctact email",
+)
+@click.option(
+ "--contact-phone",
+ "-p",
+ type=str,
+ required=False,
+ help="Conctact phone number",
+)
+@pass_latch_sdk
+def update(
+ latch: LatchSDK,
+ application_id: str,
+ 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(
+ application_id,
+ name=name,
+ two_factor=two_factor,
+ lock_on_request=lock_on_request,
+ contact_mail=contact_mail,
+ contact_phone=contact_phone,
+ )
+
+ click.secho("Application updated", bg="green")
+
+
+@application.command(name="list")
+@pass_latch_sdk
+def lst(
+ latch: LatchSDK,
+):
+ """List registered applications"""
+ applications = latch.application_list()
+
+ render_applications(applications)
+
+
+@application.command(name="remove")
+@click.argument("APPLICATION_ID", type=str, required=True)
+@pass_latch_sdk
+def remove(
+ latch: LatchSDK,
+ application_id: str,
+):
+ """Remove a given application"""
+ latch.application_remove(
+ application_id=application_id,
+ )
+
+ click.secho("Application removed", bg="green")
diff --git a/src/latch_sdk/cli/authorization_control.py b/src/latch_sdk/cli/authorization_control.py
new file mode 100644
index 0000000..445f6ef
--- /dev/null
+++ b/src/latch_sdk/cli/authorization_control.py
@@ -0,0 +1,57 @@
+# 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 Optional
+
+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: Optional[str],
+):
+ """
+ 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/instance.py b/src/latch_sdk/cli/instance.py
new file mode 100644
index 0000000..de91db7
--- /dev/null
+++ b/src/latch_sdk/cli/instance.py
@@ -0,0 +1,125 @@
+# 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 Optional
+
+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: Optional[str]):
+ """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(
+ ctx.obj["account_id"], name, 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: Optional[str],
+ two_factor: Optional[ExtraFeature],
+ lock_on_request: Optional[ExtraFeature],
+):
+ """Update a given instance"""
+ latch.instance_update(
+ ctx.obj["account_id"],
+ instance_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(
+ ctx.obj["account_id"],
+ instance_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..1869eaf
--- /dev/null
+++ b/src/latch_sdk/cli/operation.py
@@ -0,0 +1,141 @@
+# 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 Optional
+
+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: Optional[str]):
+ """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: Optional[str],
+ two_factor: Optional[ExtraFeature],
+ lock_on_request: Optional[ExtraFeature],
+):
+ """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
new file mode 100644
index 0000000..b3a24c6
--- /dev/null
+++ b/src/latch_sdk/cli/renders.py
@@ -0,0 +1,252 @@
+# 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 Iterable
+
+import click
+
+from ..models import (
+ TOTP,
+ Application,
+ ApplicationCreateResponse,
+ ApplicationHistory,
+ Client,
+ HistoryEntry,
+ HistoryResponse,
+ Identity,
+ Instance,
+ Operation,
+ Status,
+ SubscriptionUsage,
+ UserSubscription,
+)
+
+
+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_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)
+ 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(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_history(app: ApplicationHistory, 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}")
+
+
+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}")
+
+
+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)
+
+
+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/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/types.py b/src/latch_sdk/cli/types.py
new file mode 100644
index 0000000..da2b009
--- /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
new file mode 100644
index 0000000..fb55c87
--- /dev/null
+++ b/src/latch_sdk/cli/utils.py
@@ -0,0 +1,23 @@
+# 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 ..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/exceptions.py b/src/latch_sdk/exceptions.py
new file mode 100644
index 0000000..bec6023
--- /dev/null
+++ b/src/latch_sdk/exceptions.py
@@ -0,0 +1,165 @@
+# 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
+
+"""
+Latch exceptions.
+"""
+
+from typing import Optional
+
+from .models import Status
+
+_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 PairingWarning(LatchWarning):
+ account_id: Optional[str] = None
+
+
+class StatusWarning(LatchWarning):
+ status: Optional[Status] = None
+
+
+class OpenGatewayWarning(PairingWarning, StatusWarning):
+ 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(PairingWarning):
+ CODE = 205
+
+
+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
+
+
+class AccountPairedButDisabled(PairingWarning):
+ 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
new file mode 100644
index 0000000..c29345c
--- /dev/null
+++ b/src/latch_sdk/models.py
@@ -0,0 +1,722 @@
+# 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
+
+"""
+Transfer data models.
+"""
+
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from enum import Enum
+from typing import TYPE_CHECKING, Any, Iterable, Optional, TypedDict
+
+if TYPE_CHECKING: # pragma: no cover
+ from typing import Self
+
+
+@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(
+ token=data["token"],
+ generated=datetime.fromtimestamp(data["generated"] / 1000, tz=timezone.utc),
+ )
+
+
+@dataclass(frozen=True)
+class Status:
+ #: Operation identifier.
+ operation_id: str
+
+ #: False means the latch is closed and any action must be blocked.
+ status: bool
+
+ #: Two factor data if it is required.
+ two_factor: Optional[TwoFactor] = 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(
+ 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):
+ #: 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: Optional[str] = None
+
+ #: State of `Two factor` feature.
+ two_factor: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED
+
+ #: State of `Lock on request` feature.
+ lock_on_request: ExtraFeatureStatus = ExtraFeatureStatus.DISABLED
+
+ #: False means the latch is close and any action must be blocked.
+ status: Optional[bool] = None
+
+ @classmethod
+ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self":
+ """
+ Builds model from dict
+ """
+
+ 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)
+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(
+ 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)
+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: Optional[Any]
+
+ #: What is the new value.
+ value: Optional[Any]
+
+ #: 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(
+ 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)
+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:
+ """
+ Latch Application information
+ """
+
+ #: Application identifier.
+ application_id: str
+
+ #: Application name.
+ name: str
+
+ #: Application description.
+ description: str
+
+ #: 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: Optional[str] = None
+
+ #: Contact phone number.
+ contact_phone: Optional[str] = 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. False means the latch is closed and any action must be blocked.
+ 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: Optional[str] = None
+
+ #: Contact phone number.
+ contact_phone: Optional[str] = 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(
+ 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)
+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: ApplicationHistory
+
+ #: 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: Optional[datetime] = None
+
+ @classmethod
+ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self":
+ """
+ Builds model from dict
+ """
+
+ data.update(kwargs)
+
+ app_id = next(
+ iter(set(data.keys()) - {"count", "clientVersion", "lastSeen", "history"})
+ )
+
+ return cls(
+ application=ApplicationHistory.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"]],
+ )
+
+
+@dataclass(frozen=True)
+class Identity:
+ """
+ User identity model.
+ """
+
+ #: User identifier.
+ id: str
+
+ #: User name
+ 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 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
+ 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.strptime(data["createdAt"], "%Y-%m-%dT%H:%M:%SZ"),
+ disabled_by_subscription_limit=data["disabledBySubscriptionLimit"],
+ qr=data["qr"],
+ uri=data["uri"],
+ )
+
+
+class ErrorData(TypedDict):
+ #: Error code
+ code: int
+
+ #: Error message
+ message: str
+
+
+@dataclass(frozen=True)
+class Error:
+ """
+ Error model
+ """
+
+ #: Error code
+ code: int
+
+ #: Error message
+ message: str
+
+ @classmethod
+ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self":
+ """
+ Builds model from dict
+ """
+
+ data.update(kwargs)
+
+ 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.
+ 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.
+ """
+
+ #: Response data
+ data: "dict[str, Any] | None" = None
+
+ #: Response error data
+ error: Optional[Error] = None
+
+ @classmethod
+ def build_from_dict(cls, data: dict[str, Any], **kwargs) -> "Self":
+ """
+ Builds model from dict
+ """
+
+ data.update(kwargs)
+
+ 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:
+ """
+ Raise an exception if response contains an error
+ """
+ from .exceptions import BaseLatchException
+
+ if not self.error:
+ 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/py.typed b/src/latch_sdk/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/latch_sdk/sansio.py b/src/latch_sdk/sansio.py
new file mode 100644
index 0000000..faafcdf
--- /dev/null
+++ b/src/latch_sdk/sansio.py
@@ -0,0 +1,1404 @@
+# 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
+
+"""
+Base Sansio classes. They implement all logic with on input/output processes.
+"""
+
+from abc import ABC, abstractmethod
+from datetime import datetime, timezone
+from string import Template
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Awaitable,
+ Callable,
+ Generic,
+ Iterable,
+ Literal,
+ Optional,
+ TypedDict,
+ TypeVar,
+ cast,
+)
+from urllib.parse import urlparse
+
+from .exceptions import (
+ OpenGatewayWarning,
+ PairingWarning,
+ StatusWarning,
+)
+from .models import (
+ TOTP,
+ Application,
+ ApplicationCreateResponse,
+ ExtraFeature,
+ HistoryResponse,
+ Instance,
+ Operation,
+ Response,
+ Status,
+ UserSubscription,
+)
+
+try:
+ from collections.abc import Mapping
+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,
+ )
+
+
+#: 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"
+ 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_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"
+ 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"
+ AUTHORIZATION_METHOD = "11PATHS"
+ AUTHORIZATION_HEADER_FIELD_SEPARATOR = " "
+
+ DATE_HEADER_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 = "3.0",
+ host: str = "latch.tu.com",
+ port: int = 443,
+ is_https: bool = True,
+ 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
+
+ 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:
+ """
+ Host name for Latch service.
+ """
+ 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:
+ """
+ Port number for Latch service.
+ """
+ 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:
+ """
+ Whether to use TLS layer or not.
+ """
+ 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) -> Optional[str]:
+ """
+ Proxy host name.
+ """
+ return self._proxy_host
+
+ @proxy_host.setter
+ def proxy_host(self, value: Optional[str]):
+ 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) -> Optional[int]:
+ """
+ Proxy port number.
+ """
+ return self._proxy_port
+
+ @proxy_port.setter
+ def proxy_port(self, value: Optional[int]):
+ if self._proxy_port == value:
+ return
+
+ self._proxy_port = value
+ self._reconfigure_session()
+
+ @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
+ ).safe_substitute({"version": api_version}),
+ "account_pair": Template(cls.API_PATH_PAIR_PATTERN).safe_substitute(
+ {"version": api_version}
+ ),
+ "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}
+ ),
+ "account_lock": Template(cls.API_PATH_LOCK_PATTERN).safe_substitute(
+ {"version": api_version}
+ ),
+ "account_unlock": Template(cls.API_PATH_UNLOCK_PATTERN).safe_substitute(
+ {"version": api_version}
+ ),
+ "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}
+ ),
+ "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}
+ ),
+ "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
+ 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:
+ """
+ Get authorization method from header.
+
+ :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:
+ """
+ Get application identifier from header.
+
+ :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:
+ """
+ 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)
+
+ 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 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,
+ path_and_query: str,
+ *,
+ headers: "Mapping[str, str] | None" = None,
+ dt: Optional[datetime] = 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 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
+ """
+ dt_str = (dt or datetime.now(tz=timezone.utc)).strftime(self.DATE_HEADER_FORMAT)
+
+ authorization_header = self.AUTHORIZATION_HEADER_FIELD_SEPARATOR.join(
+ [
+ self.AUTHORIZATION_METHOD,
+ self.app_id,
+ self.sign_data(
+ self.build_data_to_sign(
+ http_method,
+ dt_str,
+ path_and_query,
+ headers=headers,
+ params=params,
+ )
+ ),
+ ]
+ )
+
+ return {
+ self.AUTHORIZATION_HEADER_NAME: authorization_header,
+ self.DATE_HEADER_NAME: dt_str,
+ }
+
+ @classmethod
+ def build_data_to_sign(
+ cls,
+ method: 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(),
+ dt_str.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 headers: a non neccesarily ordered map (array without duplicates) of the HTTP headers to be ordered.
+ :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:
+ 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:
+ """
+ Get paramaters serialized.
+
+ :param params: Parameters to serialize.
+ """
+ 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: Optional[str] = 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
+ 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,
+ *,
+ 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,
+ )
+ 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 _prepare_account_pair_params(
+ self,
+ *,
+ 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] = {}
+
+ 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: 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).
+
+ 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["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 len(params) == 0:
+ return self._prepare_http("GET", path)
+
+ return self._prepare_http("POST", path, params=params)
+
+ def account_pair(
+ self,
+ token: str,
+ *,
+ 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.
+
+ 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["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 len(params) == 0:
+ return self._prepare_http("GET", path)
+
+ return self._prepare_http("POST", path, params=params)
+
+ def account_status(
+ self,
+ account_id: str,
+ *,
+ instance_id: Optional[str] = None,
+ operation_id: Optional[str] = None,
+ silent: bool = False,
+ nootp: bool = False,
+ 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
+ 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["account_status"], account_id]
+
+ if operation_id:
+ parts.extend(("op", operation_id))
+
+ if instance_id:
+ parts.extend(("i", instance_id))
+
+ if nootp:
+ parts.append("nootp")
+ 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: Optional[str] = None,
+ phone_number: Optional[str] = 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["account_unpair"], account_id))
+ )
+
+ def account_lock(
+ self,
+ account_id: str,
+ *,
+ instance_id: Optional[str] = None,
+ operation_id: Optional[str] = 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["account_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 account_unlock(
+ self,
+ account_id: str,
+ *,
+ instance_id: Optional[str] = None,
+ operation_id: Optional[str] = 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["account_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 account_history(
+ self,
+ account_id: str,
+ *,
+ from_dt: Optional[datetime] = None,
+ to_dt: Optional[datetime] = None,
+ ):
+ """
+ Get history account.
+
+ :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)
+
+ return self._prepare_http(
+ "GET",
+ "/".join(
+ (
+ self._paths["account_history"],
+ account_id,
+ str(round(from_dt.timestamp() * 1000)),
+ str(round(to_dt.timestamp() * 1000)),
+ )
+ ),
+ )
+
+ def authorization_control_status(
+ self,
+ control_id: str,
+ *,
+ location: Optional[str] = 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,
+ name: str,
+ *,
+ 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,
+ "two_factor": two_factor.value,
+ "lock_on_request": lock_on_request.value,
+ }
+ return self._prepare_http("PUT", self._paths["operation"], params=params)
+
+ def operation_update(
+ self,
+ operation_id: str,
+ *,
+ name: Optional[str] = None,
+ two_factor: Optional[ExtraFeature] = None,
+ lock_on_request: Optional[ExtraFeature] = None,
+ ):
+ """
+ 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)), params=params
+ )
+
+ 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 operation_list(self, *, parent_id: Optional[str] = None):
+ """
+ Get a list of operations.
+
+ :param parent_id: To filter by parent operation.
+ """
+ parts = [self._paths["operation"]]
+
+ if parent_id is not None:
+ parts.append(parent_id)
+
+ return self._prepare_http("GET", "/".join(parts))
+
+ def instance_list(self, account_id: str, *, operation_id: Optional[str] = None):
+ """
+ Get a list of instances.
+
+ :param account_id: The account identifier.
+ :param operation_id: The operation identifier.
+ """
+ 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_create(
+ self, account_id: str, name: str, *, operation_id: Optional[str] = None
+ ):
+ """
+ Create an 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
+ 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),
+ params=params,
+ )
+
+ def instance_update(
+ self,
+ account_id: str,
+ instance_id: str,
+ *,
+ operation_id: Optional[str] = None,
+ name: Optional[str] = None,
+ two_factor: Optional[ExtraFeature] = None,
+ lock_on_request: Optional[ExtraFeature] = 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:
+ 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),
+ params=params,
+ )
+
+ def instance_remove(
+ self, account_id: str, instance_id: str, operation_id: Optional[str] = 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:
+ parts.extend(("op", operation_id))
+
+ parts.extend(("i", instance_id))
+
+ 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 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.
+ """
+ return self._prepare_http("GET", self._paths["application"])
+
+ def application_update(
+ self,
+ application_id: str,
+ *,
+ 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
+
+ :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))
+ )
+
+
+#: Factory type alias.
+TFactory: "TypeAlias" = Callable[[Response], "TReturnType"]
+
+
+class LatchSDKSansIO(Generic[TResponse]):
+ """
+ Latch SDK sans-io.
+ """
+
+ def __init__(self, core: LatchSansIO[TResponse]):
+ """
+ :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 no error.
+ """
+ resp.raise_on_error()
+
+ return True
+
+
+def response_account_pair(resp: Response) -> str:
+ """
+ :return: Account identifier.
+ """
+ try:
+ resp.raise_on_error()
+ except (PairingWarning, OpenGatewayWarning) 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 _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:
+ """
+ :return: Status object.
+ """
+ try:
+ resp.raise_on_error()
+ except (StatusWarning, OpenGatewayWarning) as ex:
+ ex.status = _build_account_status_from_response(resp)
+
+ raise ex
+
+ return _build_account_status_from_response(resp)
+
+
+def response_account_history(resp: Response) -> HistoryResponse:
+ """
+ :return: History response object.
+ """
+ resp.raise_on_error()
+
+ assert resp.data is not None, "No error or data"
+
+ 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:
+ """
+ :return: Operation object.
+ """
+ resp.raise_on_error()
+
+ 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: Response) -> Iterable[Operation]:
+ """
+ :return: Operation object list.
+ """
+ resp.raise_on_error()
+
+ 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: Response) -> Iterable[Instance]:
+ """
+ :return: Instance object list.
+ """
+ resp.raise_on_error()
+
+ 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_instance_create(resp: Response) -> str:
+ """
+ :return: Instance identifier.
+ """
+ resp.raise_on_error()
+
+ assert resp.data is not None, "No error or data"
+
+ return next(iter(resp.data.keys()))
+
+
+def response_totp(resp: Response) -> TOTP:
+ """
+ :return: TOTP object.
+ """
+ resp.raise_on_error()
+
+ assert resp.data is not None, "No error or data"
+
+ 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.
+ """
+ resp.raise_on_error()
+
+ 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]:
+ """
+ :return: Application object list.
+ """
+ resp.raise_on_error()
+
+ 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
new file mode 100644
index 0000000..d1feb54
--- /dev/null
+++ b/src/latch_sdk/syncio/__init__.py
@@ -0,0 +1,31 @@
+# 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
+
+"""
+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.
+
+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
+
+__all__ = ["LatchSDK"]
diff --git a/src/latch_sdk/syncio/base.py b/src/latch_sdk/syncio/base.py
new file mode 100644
index 0000000..3831c36
--- /dev/null
+++ b/src/latch_sdk/syncio/base.py
@@ -0,0 +1,123 @@
+# 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
+
+"""
+Base classes for asynchronous environments.
+"""
+
+from typing import TYPE_CHECKING, Any, Callable
+
+from ..models import Response
+from ..sansio import (
+ LatchSansIO,
+ LatchSDKSansIO,
+ P,
+ TFactory,
+ TReturnType,
+ response_account_history,
+ response_account_pair,
+ response_account_status,
+ response_application_create,
+ response_application_list,
+ response_authorization_control_status,
+ response_instance_create,
+ response_instance_list,
+ response_no_error,
+ response_operation,
+ response_operation_list,
+ response_subscription,
+ response_totp,
+)
+from ..utils import wraps_and_replace_return
+
+if TYPE_CHECKING: # pragma: no cover
+ from typing import Concatenate
+
+
+class BaseLatch(LatchSansIO[Response]):
+ pass
+
+
+def wrap_method(
+ factory: "TFactory[TReturnType]",
+ 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"], factory_docs=factory.__doc__
+ )
+ def wrapper(self, /, *args: P.args, **kwargs: P.kwargs) -> TReturnType:
+ return factory(getattr(self._core, meth.__name__)(*args, **kwargs))
+
+ return wrapper
+
+
+class LatchSDK(LatchSDKSansIO[Response]):
+ """
+ Latch SDK synchronous main class.
+ """
+
+ account_pair = wrap_method(response_account_pair, BaseLatch.account_pair)
+ account_pair_with_id = wrap_method(
+ response_account_pair,
+ BaseLatch.account_pair_with_id,
+ )
+
+ account_unpair = wrap_method(response_no_error, BaseLatch.account_unpair)
+
+ account_status = wrap_method(response_account_status, BaseLatch.account_status)
+
+ account_lock = wrap_method(response_no_error, BaseLatch.account_lock)
+ account_unlock = wrap_method(response_no_error, BaseLatch.account_unlock)
+
+ 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)
+ 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_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)
+
+ authorization_control_status = wrap_method(
+ 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
+ )
+ 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
new file mode 100644
index 0000000..6cf5971
--- /dev/null
+++ b/src/latch_sdk/syncio/httpx.py
@@ -0,0 +1,85 @@
+# 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 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:
+
+.. code-block:: bash
+
+ $ pip install latch-sdk-telefonica[httpx]
+
+.. warning:: If extra requirements are no satisfied an error will rise on module import.
+"""
+
+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 typing import Optional
+
+from .base import BaseLatch
+from ..models import Response
+
+try:
+ from collections.abc import Mapping
+except ImportError: # pragma: no cover
+ from typing import Mapping
+
+
+class Latch(BaseLatch):
+ """
+ Latch core class using synchronous `httpx `_ library.
+ """
+
+ _session: Client
+
+ def _reconfigure_session(self) -> None:
+ proxy: Optional[str] = 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,
+ ) -> Response:
+ """
+ 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 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
new file mode 100644
index 0000000..289d590
--- /dev/null
+++ b/src/latch_sdk/syncio/pure.py
@@ -0,0 +1,94 @@
+# 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 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 ..models import Response
+
+try:
+ from collections.abc import Mapping
+except ImportError: # pragma: no cover
+ from typing import Mapping
+
+
+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,
+ ) -> Response:
+ """
+ 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")
+
+ 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
new file mode 100644
index 0000000..d580a0c
--- /dev/null
+++ b/src/latch_sdk/syncio/requests.py
@@ -0,0 +1,85 @@
+# 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 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:
+ 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 ..models import Response
+
+try:
+ from collections.abc import Mapping
+except ImportError: # pragma: no cover
+ from typing import Mapping
+
+
+class Latch(BaseLatch):
+ """
+ Latch core class using synchronous `requests `_ library.
+ """
+
+ _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,
+ ) -> Response:
+ """
+ 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 Response.build_from_dict(response.json() if response.text else {})
diff --git a/src/latch_sdk/utils.py b/src/latch_sdk/utils.py
new file mode 100644
index 0000000..3662534
--- /dev/null
+++ b/src/latch_sdk/utils.py
@@ -0,0 +1,83 @@
+# 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
+
+"""
+Global utilities.
+"""
+
+from functools import wraps
+from typing import TYPE_CHECKING, Any, Callable, Optional, 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(
+ secret: bytes,
+ data: bytes,
+) -> bytes:
+ """
+ Signs data using a secret.
+
+ :param secret: Secret to use to sign `data`.
+ :param data: Data to sign.
+ """
+
+ 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,
+ *,
+ factory_docs: Optional[str] = None,
+) -> (
+ "Callable[[Callable[Concatenate[TSelf, P], T]], Callable[Concatenate[TSelf, P], T]]"
+):
+ """
+ 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]":
+ wrapped = wraps(meth)(f)
+ wrapped.__doc__ = "\n\n".join((meth.__doc__ or "", factory_docs or "")).strip(
+ "\n"
+ )
+ wrapped.__name__ = meth.__name__
+ wrapped.__annotations__["return"] = return_type
+
+ return wrapped
+
+ return inner
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..cb1b655
--- /dev/null
+++ b/tests/asyncio/__init__.py
@@ -0,0 +1,15 @@
+# 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
new file mode 100644
index 0000000..1b48d74
--- /dev/null
+++ b/tests/asyncio/test_aiohttp.py
@@ -0,0 +1,222 @@
+# 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
+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.account_status_on()
+ )
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "http://foo.bar.com"
+
+ 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/3.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.account_pair()
+ )
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "http://foo.bar.com"
+
+ await latch.account_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/3.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.account_status_on()
+ )
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "https://foo.bar.com"
+
+ 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/3.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.account_pair()
+ )
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "https://foo.bar.com"
+
+ await latch.account_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/3.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.account_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.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/3.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.account_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.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/3.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..9348f57
--- /dev/null
+++ b/tests/asyncio/test_base.py
@@ -0,0 +1,157 @@
+# 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 AsyncMock
+
+from latch_sdk.asyncio.base import LatchSDK
+from latch_sdk.exceptions import ApplicationAlreadyPaired, InvalidTOTP, TokenNotFound
+from latch_sdk.models import Response
+
+from ..factory import ResponseFactory
+
+
+class LatchSDKTestCase(IsolatedAsyncioTestCase):
+ API_ID = "65y4ujtngbvfasesurikjfhdgxr"
+ SECRET = "u6i7ktundsvry6budjydftrtg67tkrmf+svrysudjfvf"
+
+ def setUp(self) -> None:
+ self.core = AsyncMock()
+ self.latch_sdk = LatchSDK(self.core)
+
+ return super().setUp()
+
+ async def test_account_pair(self):
+ 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.build_from_dict(
+ ResponseFactory.account_pair_error_206_token_expired()
+ )
+
+ with self.assertRaises(TokenNotFound):
+ await self.latch_sdk.account_pair("terwrw")
+
+ async def test_account_pair_error_205_already_paired(self):
+ self.core.account_pair.return_value = Response.build_from_dict(
+ ResponseFactory.account_pair_error_205_already_paired("test_account")
+ )
+
+ with self.assertRaises(ApplicationAlreadyPaired) as ex:
+ 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.build_from_dict(
+ 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.build_from_dict(
+ 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.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.build_from_dict(
+ 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.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.build_from_dict(
+ 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.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.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.build_from_dict(
+ 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
new file mode 100644
index 0000000..d9489bf
--- /dev/null
+++ b/tests/asyncio/test_httpx.py
@@ -0,0 +1,234 @@
+# 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
+
+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.account_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.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/3.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.account_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.account_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/3.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.account_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.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/3.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.account_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.account_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/3.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.account_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.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/3.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.account_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.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/3.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..68b58e5
--- /dev/null
+++ b/tests/factory.py
@@ -0,0 +1,285 @@
+# 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 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 account_pair(
+ cls,
+ account_id: str = "ngcmkRi38JWiJ8XmeNuDThdcUTYRUfd6ryE9EeRGZdn8zjHXpvFHEzLJpVKguzCw",
+ ):
+ return {"data": {"accountId": account_id}}
+
+ @classmethod
+ def account_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 account_pair_error_206_token_expired(
+ cls, message: str = "Token not found or expired"
+ ):
+ return cls.error(206, message)
+
+ @classmethod
+ def account_status(
+ cls,
+ status: 'Literal["on"] | Literal["off"]',
+ operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ",
+ ):
+ return {"data": {"operations": {operation_id: {"status": status}}}}
+
+ @classmethod
+ def account_status_on(
+ cls,
+ operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ",
+ ):
+ return cls.account_status("on", operation_id)
+
+ @classmethod
+ def account_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 account_status_off(
+ cls,
+ operation_id: str = "aD2Gm8s9b9c9CcNEGiWJ",
+ ):
+ 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 {
+ "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",
+ },
+ ],
+ }
+ }
+
+ @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",
+ },
+ }
+ }
+
+ @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 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 {
+ "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
new file mode 100644
index 0000000..cb1b655
--- /dev/null
+++ b/tests/syncio/__init__.py
@@ -0,0 +1,15 @@
+# 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
new file mode 100644
index 0000000..950e123
--- /dev/null
+++ b/tests/syncio/test_base.py
@@ -0,0 +1,155 @@
+# 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 Mock
+
+from latch_sdk.exceptions import ApplicationAlreadyPaired, InvalidTOTP, TokenNotFound
+from latch_sdk.models import Response
+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_account_pair(self):
+ 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.build_from_dict(
+ ResponseFactory.account_pair_error_206_token_expired()
+ )
+
+ with self.assertRaises(TokenNotFound):
+ self.latch_sdk.account_pair("terwrw")
+
+ def test_account_pair_error_205_already_paired(self):
+ self.core.account_pair.return_value = Response.build_from_dict(
+ ResponseFactory.account_pair_error_205_already_paired("test_account")
+ )
+
+ with self.assertRaises(ApplicationAlreadyPaired) as ex:
+ 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.build_from_dict(
+ 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.build_from_dict(
+ 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.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.build_from_dict(
+ 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.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.build_from_dict(
+ 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.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.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.build_from_dict(
+ 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
new file mode 100644
index 0000000..fce43f5
--- /dev/null
+++ b/tests/syncio/test_httpx.py
@@ -0,0 +1,194 @@
+# 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
+
+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.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/3.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.account_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/3.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.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/3.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.account_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/3.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.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/3.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.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/3.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..552dc20
--- /dev/null
+++ b/tests/syncio/test_pure.py
@@ -0,0 +1,234 @@
+# 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
+
+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
+ http_conn_mock.getresponse.return_value.read.return_value = b"{}"
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "http://foo.bar.com"
+
+ 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/3.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
+ http_conn_mock.getresponse.return_value.read.return_value = b"{}"
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "http://foo.bar.com"
+
+ 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/3.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
+ https_conn_mock.getresponse.return_value.read.return_value = b"{}"
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "https://foo.bar.com"
+
+ 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/3.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
+ https_conn_mock.getresponse.return_value.read.return_value = b"{}"
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.host = "https://foo.bar.com"
+
+ 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/3.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
+ https_conn_mock.getresponse.return_value.read.return_value = b"{}"
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.proxy_host = "proxy.bar.com:8443"
+ latch.host = "http://foo.bar.com"
+
+ 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/3.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
+ https_conn_mock.getresponse.return_value.read.return_value = b"{}"
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.proxy_host = "proxy.bar.com:8443"
+ latch.host = "http://foo.bar.com:8080"
+
+ 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/3.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
+ https_conn_mock.getresponse.return_value.read.return_value = b"{}"
+
+ latch = Latch(self.API_ID, self.SECRET)
+
+ latch.proxy_host = "proxy.bar.com:8443"
+ latch.host = "https://foo.bar.com"
+
+ 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/3.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..c6bbaf6
--- /dev/null
+++ b/tests/syncio/test_requests.py
@@ -0,0 +1,204 @@
+# 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
+
+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.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/3.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.account_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/3.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.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/3.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.account_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/3.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.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/3.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.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/3.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..1712f94
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,47 @@
+# 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 (
+ 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_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
new file mode 100644
index 0000000..f1a86da
--- /dev/null
+++ b/tests/test_sansio.py
@@ -0,0 +1,2895 @@
+# 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, Optional
+from unittest import TestCase
+from unittest.mock import Mock, patch
+
+from parametrize import parametrize
+
+from latch_sdk.exceptions import (
+ AccountDisabledBySubscription,
+ ApplicationAlreadyPaired,
+ ApplicationNotFound,
+ MaxActionsExceed,
+ TokenNotFound,
+)
+from latch_sdk.models import (
+ TOTP,
+ Application,
+ ApplicationCreateResponse,
+ ExtraFeature,
+ HistoryResponse,
+ Instance,
+ Operation,
+ Response,
+ Status,
+)
+from latch_sdk.sansio import (
+ LatchSansIO,
+ UserSubscription,
+ response_account_history,
+ response_account_pair,
+ response_account_status,
+ response_application_create,
+ response_application_list,
+ response_authorization_control_status,
+ response_instance_create,
+ response_instance_list,
+ response_no_error,
+ response_operation,
+ response_operation_list,
+ response_subscription,
+ response_totp,
+)
+from latch_sdk.utils import sign_data
+
+from .factory import ResponseFactory
+
+
+class ResponseAccountPairTestCase(TestCase):
+ def test_no_error(self) -> None:
+ 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.build_from_dict(
+ ResponseFactory.account_pair_error_206_token_expired("test message")
+ )
+
+ with self.assertRaises(TokenNotFound) as ex:
+ response_account_pair(resp)
+
+ self.assertEqual(ex.exception.message, "test message")
+
+ def test_with_error_already_paired(self) -> None:
+ resp = Response.build_from_dict(
+ ResponseFactory.account_pair_error_205_already_paired(
+ "test_account", "test message"
+ )
+ )
+
+ with self.assertRaises(ApplicationAlreadyPaired) as ex:
+ response_account_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 = Response.build_from_dict(ResponseFactory.no_data())
+
+ self.assertTrue(response_no_error(resp))
+
+ def test_with_error(self) -> None:
+ resp = Response.build_from_dict(
+ ResponseFactory.error(ApplicationNotFound.CODE, "test message")
+ )
+
+ with self.assertRaises(ApplicationNotFound) as ex:
+ response_no_error(resp)
+
+ self.assertEqual(ex.exception.message, "test message")
+
+
+class ResponseAccountStatusTestCase(TestCase):
+ def test_no_error(self) -> None:
+ resp = Response.build_from_dict(ResponseFactory.account_status_on("account_id"))
+
+ 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.build_from_dict(
+ ResponseFactory.account_status_on_two_factor("account_id", "123456")
+ )
+
+ status = response_account_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 = Response.build_from_dict(
+ {"error": {"code": MaxActionsExceed.CODE, "message": "test message"}}
+ )
+
+ with self.assertRaises(MaxActionsExceed) as ex:
+ response_account_status(resp)
+
+ self.assertEqual(ex.exception.message, "test message")
+
+ def test_with_error702_disabled_by_subscription(self) -> None:
+ resp = Response.build_from_dict(
+ 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:
+ resp = Response.build_from_dict(
+ {"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 = Response.build_from_dict(
+ {"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 = Response.build_from_dict(
+ {"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 = Response.build_from_dict(
+ {
+ "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 = Response.build_from_dict({"data": {}})
+
+ operation_list = iter(response_operation_list(resp))
+
+ with self.assertRaises(StopIteration):
+ next(operation_list)
+
+ def test_with_error(self) -> None:
+ resp = Response.build_from_dict(
+ {"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 = Response.build_from_dict(
+ {
+ "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 = Response.build_from_dict({"data": {}})
+
+ operation_list = iter(response_instance_list(resp))
+
+ with self.assertRaises(StopIteration):
+ next(operation_list)
+
+ def test_with_error(self) -> None:
+ resp = Response.build_from_dict(
+ {"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 ResponseInstanceCreateTestCase(TestCase):
+ def test_no_error(self) -> None:
+ 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.build_from_dict(
+ {"error": {"code": ApplicationNotFound.CODE, "message": "test message"}}
+ )
+
+ with self.assertRaises(ApplicationNotFound) as ex:
+ response_instance_create(resp)
+
+ self.assertEqual(ex.exception.message, "test message")
+
+
+class ResponseAccountHistoryTestCase(TestCase):
+ def test_no_error(self) -> None:
+ resp = Response.build_from_dict(ResponseFactory.account_history())
+
+ data = response_account_history(resp)
+
+ self.assertIsInstance(data, HistoryResponse)
+
+
+class ResponseTotpTestCase(TestCase):
+ def test_no_error(self) -> None:
+ resp = Response.build_from_dict(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 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 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())
+
+ data = response_application_create(resp)
+
+ self.assertIsInstance(data, ApplicationCreateResponse)
+ self.assertIsNotNone(data.application_id)
+ self.assertIsNotNone(data.secret)
+
+
+class ResponseApplicationListTestCase(TestCase):
+ def test_no_error(self) -> None:
+ resp = Response.build_from_dict(ResponseFactory.application_list())
+
+ data = response_application_list(resp)
+
+ 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.build_from_dict(ResponseFactory.application_list_empty())
+
+ data = response_application_list(resp)
+
+ for app in data:
+ self.fail("No empty list applications")
+
+
+class LatchClassMethodsTestCase(TestCase):
+ def test_build_paths(self):
+ self.assertEqual(
+ LatchSansIO.build_paths("v1"),
+ {
+ "application": "/api/v1/application",
+ "account_status": "/api/v1/status",
+ "account_history": "/api/v1/history",
+ "instance": "/api/v1/instance",
+ "account_lock": "/api/v1/lock",
+ "operation": "/api/v1/operation",
+ "account_pair": "/api/v1/pair",
+ "account_pair_with_id": "/api/v1/pairWithId",
+ "subscription": "/api/v1/subscription",
+ "totp": "/api/v1/totps",
+ "account_unlock": "/api/v1/unlock",
+ "account_unpair": "/api/v1/unpair",
+ "account_metadata": "/api/v1/aliasMetadata",
+ "authorization_control_status": "/api/v1/control-status",
+ },
+ )
+
+ self.assertEqual(
+ LatchSansIO.build_paths("p2"),
+ {
+ "application": "/api/p2/application",
+ "account_status": "/api/p2/status",
+ "account_history": "/api/p2/history",
+ "instance": "/api/p2/instance",
+ "account_lock": "/api/p2/lock",
+ "operation": "/api/p2/operation",
+ "account_pair": "/api/p2/pair",
+ "account_pair_with_id": "/api/p2/pairWithId",
+ "subscription": "/api/p2/subscription",
+ "totp": "/api/p2/totps",
+ "account_unlock": "/api/p2/unlock",
+ "account_unpair": "/api/p2/unpair",
+ "account_metadata": "/api/p2/aliasMetadata",
+ "authorization_control_status": "/api/p2/control-status",
+ },
+ )
+
+ 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_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",
+ 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",
+ },
+ ),
+ )
+
+ 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",
+ params={
+ "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",
+ 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",
+ },
+ ),
+ )
+
+ 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[Response]):
+ def __init__(
+ self,
+ *args,
+ http_callback: Callable[
+ [
+ str,
+ str,
+ Mapping[str, str],
+ "Mapping[str, str] | None",
+ ],
+ Response,
+ ],
+ **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,
+ ) -> Response:
+ 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,
+ 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,
+ ).encode(),
+ ).decode(),
+ )
+
+ def test_account_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,
+ ) -> Response:
+ self.assertEqual(method, "GET")
+
+ self.assertEqual(path, "/api/3.0/pairWithId/eregerdscvrtrd")
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict({"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)
+ 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_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.build_from_dict({"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)}"
+ 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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(path, "/api/3.0/pairWithId/eregerdscvrtrd")
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(
+ params,
+ {
+ "wallet": web3_account,
+ "signature": web3_signature,
+ },
+ )
+
+ return Response.build_from_dict({"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, web3_account=web3_account, web3_signature=web3_signature
+ )
+
+ 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(self) -> None:
+ pin = "3edcvb"
+
+ 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/pair/{pin}")
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict({"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(pin)
+ 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_already_paired(self) -> None:
+ pin = "3edcvb"
+
+ 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/pair/{pin}")
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict(
+ 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.account_pair(pin)
+ self.assertIsInstance(resp, Response)
+
+ 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_account_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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(path, f"/api/3.0/pair/{pin}")
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(
+ params,
+ {
+ "wallet": web3_account,
+ "signature": web3_signature,
+ },
+ )
+
+ return Response.build_from_dict({"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(
+ pin, web3_account=web3_account, web3_signature=web3_signature
+ )
+
+ self.assertIsInstance(resp, Response)
+
+ self.assertIsNotNone(resp.data)
+ self.assertEqual(resp.data["accountId"], "latch_account_id") # type: ignore
+
+ http_cb.assert_called_once()
+
+ def assert_request_get(
+ 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_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"
+ ):
+ 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_account_status(
+ self,
+ operation_id: Optional[str],
+ instance_id: Optional[str],
+ nootp: bool,
+ silent: bool,
+ expected_path: str,
+ ) -> None:
+ account_id = "eregerdscvrtrd"
+
+ def _http_cb(
+ method: str,
+ path: str,
+ headers: Mapping[str, str],
+ params: "Mapping[str, str] | None" = None,
+ ) -> Response:
+ self.assert_request_get(method, path, headers, params)
+ self.assertEqual(
+ path,
+ Template(
+ "/api/3.0/status/${account_id}" + expected_path
+ ).safe_substitute(
+ {
+ "account_id": account_id,
+ "operation_id": operation_id,
+ "instance_id": instance_id,
+ }
+ ),
+ )
+
+ return Response.build_from_dict({"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,
+ operation_id=operation_id,
+ instance_id=instance_id,
+ nootp=nootp,
+ silent=silent,
+ )
+ self.assert_account_status_response(resp, account_id=account_id, status="on")
+
+ 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.build_from_dict({"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.build_from_dict({"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"
+
+ 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/unpair/{account_id}")
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.account_unpair(account_id)
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ @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_account_lock(
+ self,
+ operation_id: Optional[str],
+ instance_id: Optional[str],
+ expected_path: str,
+ ) -> None:
+ account_id = "eregerdscvrtrd"
+
+ 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,
+ Template("/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.account_lock(
+ account_id, operation_id=operation_id, instance_id=instance_id
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ @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_account_unlock(
+ self,
+ operation_id: Optional[str],
+ instance_id: Optional[str],
+ expected_path: str,
+ ) -> None:
+ account_id = "eregerdscvrtrd"
+
+ 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,
+ Template(
+ "/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ 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_account_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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(path, f"/api/3.0/unlock/{account_id}/op/{operation_id}")
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.account_unlock(account_id, operation_id=operation_id)
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_account_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,
+ ) -> Response:
+ self.assertEqual(method, "GET")
+
+ self.assertEqual(
+ path,
+ f"/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ 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_account_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,
+ ) -> Response:
+ self.assertEqual(method, "GET")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/history/{account_id}/0/{round(to_dt.timestamp() * 1000)}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ 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_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
+ 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,
+ ) -> Response:
+ self.assertEqual(method, "GET")
+
+ self.assertEqual(
+ path,
+ 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)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ 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_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
+ 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,
+ ) -> Response:
+ self.assertEqual(method, "GET")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/history/{account_id}/0/{round(current_dt.timestamp() * 1000)}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(params)
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.account_history(account_id)
+ self.assertIsInstance(resp, Response)
+
+ 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.build_from_dict({})
+
+ 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"
+
+ 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/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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_create(parent_id, "new_op")
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ 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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/operation/{operation_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(
+ {
+ "name": "new_op",
+ },
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_update(operation_id, name="new_op")
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ 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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/operation/{operation_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(
+ {"name": "new_op", "two_factor": ExtraFeature.MANDATORY.value},
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_update(
+ operation_id, name="new_op", two_factor=ExtraFeature.MANDATORY
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_opertion_update_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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_update(
+ operation_id, name="new_op", lock_on_request=ExtraFeature.OPT_IN
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_opertion_update_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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_update(
+ operation_id,
+ name="new_op",
+ two_factor=ExtraFeature.MANDATORY,
+ lock_on_request=ExtraFeature.OPT_IN,
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_operation_remove(self) -> None:
+ operation_id = "eregerdscvrtrd"
+
+ 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/operation/{operation_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_remove(
+ operation_id,
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_operation_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/operation",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_list()
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_operation_list_children(self) -> None:
+ operation_id = "eregerdscvrtrd"
+
+ 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/operation/{operation_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.operation_list(
+ parent_id=operation_id,
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_list(self) -> None:
+ account_id = "ryhtggfdwdffhgrd"
+
+ 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/instance/{account_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_list(account_id)
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_list_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,
+ ) -> Response:
+ self.assertEqual(method, "GET")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/instance/{account_id}/op/{operation_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_list(
+ account_id,
+ operation_id=operation_id,
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_create_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,
+ ) -> Response:
+ self.assertEqual(method, "PUT")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/instance/{account_id}/op/{operation_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(params, {"instances": "new_instance"})
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_create(
+ account_id,
+ "new_instance",
+ operation_id=operation_id,
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_create(self) -> None:
+ account_id = "ryhtggfdwdffhgrd"
+
+ 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,
+ f"/api/3.0/instance/{account_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(params, {"instances": "new_instance"})
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_create(
+ account_id,
+ "new_instance",
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_update(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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/instance/{account_id}/i/{instance_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(
+ {
+ "name": "new_op",
+ },
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_update(account_id, instance_id, name="new_op")
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_update_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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_update(
+ account_id, instance_id, name="new_op", two_factor=ExtraFeature.MANDATORY
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_update_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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_update(
+ account_id, instance_id, name="new_op", lock_on_request=ExtraFeature.OPT_IN
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_update_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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.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 Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_update(
+ account_id,
+ instance_id,
+ name="new_op",
+ two_factor=ExtraFeature.MANDATORY,
+ lock_on_request=ExtraFeature.OPT_IN,
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_operation_update(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,
+ ) -> Response:
+ self.assertEqual(method, "POST")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/instance/{account_id}/op/{operation_id}/i/{instance_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertEqual(
+ {
+ "name": "new_op",
+ },
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_update(
+ account_id, instance_id, operation_id=operation_id, name="new_op"
+ )
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_update_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.instance_update(account_id, instance_id)
+
+ http_cb.assert_not_called()
+
+ def test_instance_remove(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,
+ ) -> Response:
+ self.assertEqual(method, "DELETE")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/instance/{account_id}/i/{instance_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ resp = latch.instance_remove(account_id, instance_id)
+ self.assertIsInstance(resp, Response)
+
+ http_cb.assert_called_once()
+
+ def test_instance_remove_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,
+ ) -> Response:
+ self.assertEqual(method, "DELETE")
+
+ self.assertEqual(
+ path,
+ f"/api/3.0/instance/{account_id}/op/{operation_id}/i/{instance_id}",
+ )
+ self.assert_request(method, path, headers, params)
+
+ self.assertIsNone(
+ params,
+ )
+
+ return Response.build_from_dict({})
+
+ http_cb = Mock(side_effect=_http_cb)
+
+ latch = LatchTesting(self.API_ID, self.SECRET, http_callback=http_cb)
+
+ 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.build_from_dict(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.build_from_dict(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.build_from_dict(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.build_from_dict(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()
+
+ @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: Optional[str], 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,
+ 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.build_from_dict(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_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"
+ 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.build_from_dict(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.build_from_dict(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.build_from_dict(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.build_from_dict(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()
+
+ 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", query="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", query="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", query="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", query="param1=1¶m2=2"),
+ "http://foo.bar.com:8080/test?param1=1¶m2=2",
+ )
+
+
+# type: ignore
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..16f57ae
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,40 @@
+# 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 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))