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))