diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..1d4af3035 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,52 @@ +# Keep the Docker build context to what the image needs: the package +# source, ``pyproject.toml``, ``uv.lock`` and the ``README.rst`` that +# ``pyproject.toml`` references. This list follows the +# ``[tool.check-manifest]`` ignore list in ``pyproject.toml``, plus +# VCS data, local virtual environments and secrets. +*.enc +.checkmake-config.ini +.dockerignore +.git +.git_archival.txt +.gitattributes +.github +.gitignore +.pre-commit-config.yaml +.vscode +.prettierrc +.vale.ini +.yamlfmt +admin +CHANGELOG.rst +ci +CODE_OF_CONDUCT.rst +CONTRIBUTING.rst +docker-bake.hcl +docs +LICENSE +lint.mk +Makefile +MANIFEST.in +newsfragments +secrets.tar.gpg +spelling_private_dict.txt +tests +vuforia_secrets.env.example +zizmor.yml + +# Local development leftovers. +**/__pycache__ +**/.DS_Store +*.egg-info +.claude +.context +.coverage* +.mypy_cache +.pytest_cache +.venv +ci_secrets +conftest.py +docker_venvs +secrets.tar +styles +vuforia_secrets.env diff --git a/.gitattributes b/.gitattributes index 00a7b00c9..ee0f759ba 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ .git_archival.txt export-subst +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2cb635954 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +--- +version: 2 + +updates: + # The ``uv`` ecosystem updates ``pyproject.toml`` and ``uv.lock`` + # together, keeping the lockfile in sync so that + # ``uv sync --locked`` in the Dockerfile keeps working. + - package-ecosystem: uv + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + - package-ecosystem: pre-commit + directory: / + schedule: + interval: daily diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 000000000..0fb5455f9 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,39 @@ +--- +name: autofix.ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + autofix: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + + - name: Run fixers + uses: j178/prek-action@v3.0.0 + with: + prek-version: 0.4.11 + extra-args: >- + --all-files + --hook-stage pre-commit + --no-fail-fast + --verbose + env: + UV_NO_CACHE: '1' + UV_PYTHON: '3.14' + + - uses: autofix-ci/action@v1.3.4 + if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 447543fb4..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,114 +0,0 @@ ---- - -name: CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - schedule: - # * is a special character in YAML so you have to quote this string - # Run at 1:00 every day - - cron: '0 1 * * *' - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: [3.8.5] - ci_pattern: - - test_query.py::TestContentType - - test_query.py::TestSuccess - - test_query.py::TestIncorrectFields - - test_query.py::TestMaxNumResults - - test_query.py::TestIncludeTargetData - - test_query.py::TestAcceptHeader - - test_query.py::TestActiveFlag - - test_query.py::TestBadImage - - test_query.py::TestMaximumImageFileSize - - test_query.py::TestMaximumImageDimensions - - test_query.py::TestImageFormats - - test_query.py::TestProcessing - - test_query.py::TestUpdate - - test_query.py::TestDeleted - - test_query.py::TestTargetStatusFailed - - test_query.py::TestDateFormats - - test_query.py::TestInactiveProject - - test_add_target.py - - test_authorization_header.py::TestAuthorizationHeader - - test_authorization_header.py::TestMalformed - - test_authorization_header.py::TestBadKey - - test_content_length.py - - test_database_summary.py - - test_date_header.py::TestFormat - - test_date_header.py::TestMissing - - test_date_header.py::TestSkewedTime - - test_delete_target.py - - test_get_duplicates.py - - test_get_target.py - - test_invalid_given_id.py - - test_invalid_json.py - - test_target_list.py - - test_target_summary.py - - test_unexpected_json.py - - test_update_target.py::TestActiveFlag - - test_update_target.py::TestApplicationMetadata - - test_update_target.py::TestImage - - test_update_target.py::TestTargetName - - test_update_target.py::TestUnexpectedData - - test_update_target.py::TestUpdate - - test_update_target.py::TestWidth - - test_update_target.py::TestInactiveProject - - test_usage.py - - steps: - # We share Vuforia credentials and therefore Vuforia databases across - # workflows. - # We therefore want to run only one workflow at a time. - - name: Wait for other GitHub Workflows to finish - uses: softprops/turnstyle@v1 - with: - same-branch-only: false - # By default this is 60. - # We have a lot of jobs so this is set higher - we hit API timeouts. - poll-interval-seconds: 300 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/checkout@v2 - - name: "Set up Python" - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip setuptools wheel - # We use '--ignore-installed' to avoid GitHub's cache which can cause - # issues - we have seen packages from this cache be cause trouble with - # pip-extra-reqs. - python -m pip install --ignore-installed --upgrade --editable .[dev] - - - name: "Set secrets file" - run: | - # See the "CI Setup" document for details of how this was set up. - ci/decrypt_secret.sh - tar xvf "${HOME}"/secrets/secrets.tar - python ci/set_secrets_file.py - env: - CI_PATTERN: ${{ matrix.ci_pattern }} - ENCRYPTED_FILE: secrets.tar.gpg - LARGE_SECRET_PASSPHRASE: ${{ secrets.PASSPHRASE_FOR_VUFORIA_SECRETS }} - - - name: "Run tests" - run: | - pytest -s -vvv --exitfirst --cov=src/ --cov=tests --cov-report=xml tests/mock_vws/${{ matrix.ci_pattern }} - - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v1.0.13" - with: - fail_ci_if_error: true diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml new file mode 100644 index 000000000..69fa53039 --- /dev/null +++ b/.github/workflows/dependabot-merge.yml @@ -0,0 +1,19 @@ +--- + +name: Dependabot auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..5364e4cdd --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,45 @@ +--- +name: Build Docker images + +# This matches the Docker image building done in the release process. +# +# It is possible to use https://github.com/nektos/act to run this workflow. + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # * is a special character in YAML so you have to quote this string + # Run at 1:00 every day + - cron: 0 1 * * * + workflow_dispatch: {} + +permissions: {} + +jobs: + build: + name: Build Docker images + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Check Docker bake definition + uses: docker/bake-action@v7.3.0 + with: + call: check + + - name: Build Docker images + uses: docker/bake-action@v7.3.0 + with: + push: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d668a1a5f..0c070d40a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,42 +1,59 @@ --- - name: Lint on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] schedule: # * is a special character in YAML so you have to quote this string # Run at 1:00 every day - - cron: '0 1 * * *' + - cron: 0 1 * * * + workflow_dispatch: {} + +permissions: {} jobs: build: - - runs-on: ubuntu-latest - strategy: matrix: - python-version: [3.8] + python-version: ['3.14'] + platform: [ubuntu-latest, windows-latest] + hook-stage: [pre-commit, pre-push, manual] + + runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v2 - - name: "Set up Python" - uses: actions/setup-python@v1 + - uses: actions/checkout@v7 with: - python-version: ${{ matrix.python-version }} - - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip setuptools wheel - # We use '--ignore-installed' to avoid GitHub's cache which can cause - # issues - we have seen packages from this cache be cause trouble with - # pip-extra-reqs. - python -m pip install --ignore-installed --upgrade --editable .[dev] - sudo apt-get install -y enchant - - - name: "Lint" - run: | - make lint + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + + - name: Lint + uses: j178/prek-action@v3.0.0 + with: + prek-version: 0.4.11 + extra-args: >- + --all-files + --hook-stage ${{ matrix.hook-stage }} + --verbose + env: + # Avoid intermittent uv distribution cache rename failures while + # prek installs hook environments on Windows. + UV_NO_CACHE: '1' + UV_PYTHON: ${{ matrix.python-version }} + + completion-lint: + needs: build + runs-on: ubuntu-latest + if: always() # Run even if one matrix job fails + steps: + - name: Check matrix job status + run: |- + if ! ${{ needs.build.result == 'success' }}; then + echo "One or more matrix jobs failed" + exit 1 + fi diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml new file mode 100644 index 000000000..8172787fb --- /dev/null +++ b/.github/workflows/publish-site.yml @@ -0,0 +1,27 @@ +--- +name: Deploy documentation + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pages: + runs-on: ubuntu-latest + environment: + name: ${{ github.ref_name == 'main' && 'github-pages' || 'development' }} + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: sphinx-notes/pages@v3 + with: + documentation_path: docs/source + pyproject_extras: dev + python_version: '3.14' + sphinx_build_options: -W + publish: ${{ github.ref_name == 'main' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..39a8655ff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,167 @@ +--- +name: Release + +on: workflow_dispatch + +jobs: + release: + name: Create release + runs-on: ubuntu-latest + environment: release + + permissions: + # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. + contents: write + + outputs: + version: ${{ steps.calver.outputs.release }} + tag: ${{ steps.tag_version.outputs.new_tag }} + + steps: + - uses: actions/checkout@v7 + with: + # Fetch all history including tags. + # Needed to find the latest tag. + # + # Also, avoids + # https://github.com/stefanzweifel/git-auto-commit-action/issues/99. + fetch-depth: 0 + # Credentials need to persist for stefanzweifel/git-auto-commit-action. + # zizmor: ignore[artipacked] + persist-credentials: true + # Use a PAT so that the push from git-auto-commit-action + # can bypass repository ruleset required status checks. + # The default GITHUB_TOKEN cannot bypass rulesets. + token: ${{ secrets.RELEASE_PAT }} + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Calver calculate version + uses: StephaneBour/actions-calver@master + id: calver + with: + date_format: '%Y.%m.%d' + release: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # towncrier writes the rendered notes to stdout (informational + # chatter goes to stderr), so this is the curated release body for + # this version, not github-tag-action's commit-derived changelog. + - name: Generate the GitHub release notes + env: + RELEASE: ${{ steps.calver.outputs.release }} + run: uv run --extra=release towncrier build --draft --version "$RELEASE" > + release-notes.md + + # Assemble the same fragments into CHANGELOG.rst under a new + # ``$RELEASE`` section and delete the consumed fragment files. + - name: Update the changelog + env: + RELEASE: ${{ steps.calver.outputs.release }} + run: uv run --extra=release towncrier build --yes --version "$RELEASE" + + - uses: stefanzweifel/git-auto-commit-action@v7 + id: commit + with: + commit_message: Bump CHANGELOG + file_pattern: CHANGELOG.rst newsfragments + # Error if there are no changes. + skip_dirty_check: true + + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + custom_tag: ${{ steps.calver.outputs.release }} + tag_prefix: '' + commit_sha: ${{ steps.commit.outputs.commit_hash }} + + - name: Create a GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + makeLatest: true + name: Release ${{ steps.tag_version.outputs.new_tag }} + bodyFile: release-notes.md + + pypi: + name: Publish to PyPI + needs: release + runs-on: ubuntu-latest + + # Specifying an environment is strongly recommended by PyPI. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + environment: release + + permissions: + # This is needed for PyPI publishing. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + id-token: write + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag }} + # Fetch all history including tags. + # Needed for setuptools-scm version detection. + fetch-depth: 0 + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Build a binary wheel and a source tarball + run: | + uv build --sdist --wheel --out-dir dist/ + uv run --extra=release check-wheel-contents dist/*.whl + + # We use PyPI trusted publishing rather than a PyPI API token. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true + + docker: + name: Publish Docker images + needs: release + runs-on: ubuntu-latest + environment: dockerhub + + permissions: + packages: write + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag }} + persist-credentials: false + + - name: Login to GHCR + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Build and push Docker images + uses: docker/bake-action@v7.3.0 + with: + push: true + env: + VERSION: ${{ needs.release.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..927f7d41c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,371 @@ +--- +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # * is a special character in YAML so you have to quote this string + # Run at 1:00 every day + - cron: 0 1 * * * + workflow_dispatch: {} + +# We share Vuforia credentials and therefore Vuforia databases across +# workflows. We therefore want to run only one workflow at a time. +concurrency: vuforia_credentials + +permissions: {} + +jobs: + # CI tests with matrix + ci-tests: + runs-on: ubuntu-latest + environment: vuforia + strategy: + fail-fast: false + matrix: + python-version: ['3.14'] + ci_pattern: + - tests/mock_vws/test_query.py::TestContentType + - tests/mock_vws/test_query.py::TestSuccess + - tests/mock_vws/test_query.py::TestIncorrectFields + - tests/mock_vws/test_query.py::TestMaxNumResults + - tests/mock_vws/test_query.py::TestResultOrder + - tests/mock_vws/test_query.py::TestIncludeTargetData + - tests/mock_vws/test_query.py::TestAcceptHeader + - tests/mock_vws/test_query.py::TestActiveFlag + - tests/mock_vws/test_query.py::TestBadImage + - tests/mock_vws/test_query.py::TestMaximumImageFileSize + - tests/mock_vws/test_query.py::TestMaximumImageDimensions + - tests/mock_vws/test_query.py::TestImageFormats + - tests/mock_vws/test_query.py::TestProcessing + - tests/mock_vws/test_query.py::TestUpdate + - tests/mock_vws/test_query.py::TestDeleted + - tests/mock_vws/test_query.py::TestTargetStatusFailed + - tests/mock_vws/test_query.py::TestDateFormats + - tests/mock_vws/test_query.py::TestInactiveProject + - tests/mock_vws/test_add_target.py::TestContentTypes + - tests/mock_vws/test_add_target.py::TestMissingData + - tests/mock_vws/test_add_target.py::TestWidth + - tests/mock_vws/test_add_target.py::TestTargetName + - tests/mock_vws/test_add_target.py::TestImage + - tests/mock_vws/test_add_target.py::TestActiveFlag + - tests/mock_vws/test_add_target.py::TestUnexpectedData + - tests/mock_vws/test_add_target.py::TestApplicationMetadata + - tests/mock_vws/test_add_target.py::TestInactiveProject + - tests/mock_vws/test_authorization_header.py::TestAuthorizationHeader + - tests/mock_vws/test_authorization_header.py::TestMalformed::test_one_part_no_space + - tests/mock_vws/test_authorization_header.py::TestMalformed::test_one_part_with_space + - tests/mock_vws/test_authorization_header.py::TestMalformed::test_missing_signature + - tests/mock_vws/test_authorization_header.py::TestBadKey + - tests/mock_vws/test_cloud_query_failure_response.py + - tests/mock_vws/test_content_length.py::TestIncorrect::test_not_integer + - tests/mock_vws/test_content_length.py::TestIncorrect::test_too_large + - tests/mock_vws/test_content_length.py::TestIncorrect::test_too_small + - tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_success + - tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_active_images + - tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_failed_images + - tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_inactive_images + - tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_inactive_failed + - tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_deleted + - tests/mock_vws/test_database_summary.py::TestProcessingImages + - tests/mock_vws/test_database_summary.py::TestQuotas + - tests/mock_vws/test_database_summary.py::TestRecos + - tests/mock_vws/test_database_summary.py::TestRequestUsage + - tests/mock_vws/test_database_summary.py::TestInactiveProject + - tests/mock_vws/test_date_header.py::TestFormat + - tests/mock_vws/test_date_header.py::TestMissing + - tests/mock_vws/test_date_header.py::TestSkewedTime::test_date_out_of_range_after + - tests/mock_vws/test_date_header.py::TestSkewedTime::test_date_out_of_range_before + - tests/mock_vws/test_date_header.py::TestSkewedTime::test_date_in_range_after + - tests/mock_vws/test_date_header.py::TestSkewedTime::test_date_in_range_before + - tests/mock_vws/test_delete_target.py + - tests/mock_vws/test_get_duplicates.py + - tests/mock_vws/test_get_target.py + - tests/mock_vws/test_invalid_given_id.py + - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_invalid_json + - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_invalid_json_with_skewed_time + - tests/mock_vws/test_target_list.py + - tests/mock_vws/test_reco_counts_report.py + - tests/mock_vws/test_target_raters.py + - tests/mock_vws/test_target_summary.py + - tests/mock_vws/test_unexpected_json.py + - tests/mock_vws/test_update_target.py::TestActiveFlag + - tests/mock_vws/test_update_target.py::TestApplicationMetadata::test_base64_encoded + - tests/mock_vws/test_update_target.py::TestApplicationMetadata::test_invalid_type + - tests/mock_vws/test_update_target.py::TestApplicationMetadata::test_not_base64_encoded_processable + - tests/mock_vws/test_update_target.py::TestApplicationMetadata::test_not_base64_encoded_not_processable + - tests/mock_vws/test_update_target.py::TestApplicationMetadata::test_metadata_too_large + - tests/mock_vws/test_update_target.py::TestImage::test_image_valid + - tests/mock_vws/test_update_target.py::TestImage::test_bad_image_format_or_color_space + - tests/mock_vws/test_update_target.py::TestImage::test_corrupted + - tests/mock_vws/test_update_target.py::TestImage::test_image_too_large + - tests/mock_vws/test_update_target.py::TestImage::test_not_base64_encoded_processable + - tests/mock_vws/test_update_target.py::TestImage::test_not_base64_encoded_not_processable + - tests/mock_vws/test_update_target.py::TestImage::test_not_image + - tests/mock_vws/test_update_target.py::TestImage::test_invalid_type + - tests/mock_vws/test_update_target.py::TestImage::test_rating_can_change + - tests/mock_vws/test_update_target.py::TestTargetName::test_name_valid + - tests/mock_vws/test_update_target.py::TestTargetName::test_name_invalid + - tests/mock_vws/test_update_target.py::TestTargetName::test_existing_target_name + - tests/mock_vws/test_update_target.py::TestTargetName::test_same_name_given + - tests/mock_vws/test_update_target.py::TestUnexpectedData + - tests/mock_vws/test_update_target.py::TestUpdate + - tests/mock_vws/test_update_target.py::TestWidth + - tests/mock_vws/test_update_target.py::TestInactiveProject + - tests/mock_vws/test_requests_mock_usage.py + - tests/mock_vws/test_respx_mock_usage.py + - tests/mock_vws/test_flask_app_usage.py + - tests/mock_vws/test_model_target_generation_failure.py + - tests/mock_vws/test_model_target_generation_warning.py + - tests/mock_vws/test_model_target_web_api.py + - tests/mock_vws/test_vumark_generation_api.py + - tests/mock_vws/test_vumark_generation_failure.py + - tests/mock_vws/test_target_validators.py + - tests/mock_vws/test_healthcheck.py + - tests/mock_vws/test_docker.py + - README.rst + - docs/ + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Set secrets file + env: + CI_PATTERN: ${{ matrix.ci_pattern }} + ENCRYPTED_FILE: secrets.tar.gpg + LARGE_SECRET_PASSPHRASE: ${{ secrets.PASSPHRASE_FOR_VUFORIA_SECRETS }} + JOB_INDEX: ${{ strategy.job-index }} + run: | + # See the "CI Setup" document for details of how this was set up. + ci/decrypt_secret.sh + tar xvf "${HOME}"/secrets/secrets.tar + cp "./ci_secrets/vuforia_secrets_${JOB_INDEX}.env" ./vuforia_secrets.env + + # We have seen issues with running out of disk space on test_docker + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + if: matrix.ci_pattern == 'tests/mock_vws/test_docker.py' + with: + # All of these default to true (meaning they are removed). + docker-images: false + large-packages: false + swap-storage: false + tool-cache: false + + android: true + dotnet: true + haskell: true + + - name: Run tests + run: | + uv run --extra=dev \ + coverage run -m pytest \ + -s \ + -vvv \ + --showlocals \ + --exitfirst \ + ${{ matrix.ci_pattern }} + env: + UV_PYTHON: ${{ matrix.python-version }} + + - name: Sanitize pattern for artifact name + id: sanitize + run: | + SANITIZED_PATTERN=$(echo "${{ matrix.ci_pattern }}" | sed 's/::/-/g' | sed 's/:/-/g' | sed 's|/|-|g') + echo "name=coverage-data-ci-${{ matrix.python-version }}-${SANITIZED_PATTERN}" >> "$GITHUB_OUTPUT" + + - name: Upload coverage data + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.sanitize.outputs.name }} + path: .coverage.* + include-hidden-files: true + if-no-files-found: error + + # Skip tests + skip-tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.14'] + platform: [ubuntu-latest] + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Set secrets file + run: | + cp ./vuforia_secrets.env.example ./vuforia_secrets.env + + - name: Run tests + run: | + uv run --extra=dev \ + coverage run -m pytest \ + --skip-docker_build_tests \ + --skip-docker_in_memory \ + --skip-mock \ + --skip-real \ + --capture=no \ + -vvv \ + --exitfirst \ + . + env: + UV_PYTHON: ${{ matrix.python-version }} + + - name: Upload coverage data + uses: actions/upload-artifact@v7 + with: + name: coverage-data-skip-tests-${{ matrix.python-version }} + path: .coverage.* + include-hidden-files: true + if-no-files-found: error + + # Windows tests + windows-tests: + runs-on: windows-latest + strategy: + matrix: + python-version: ['3.14'] + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Set secrets file + run: | + cp ./vuforia_secrets.env.example ./vuforia_secrets.env + + - name: Start Docker + shell: pwsh + run: | + $service = Get-Service docker -ErrorAction Stop + if ($service.Status -ne "Running") { + Start-Service docker + } + $deadline = (Get-Date).AddMinutes(2) + do { + docker info + if ($LASTEXITCODE -eq 0) { + exit 0 + } + Start-Sleep -Seconds 5 + } while ((Get-Date) -lt $deadline) + docker info + + - name: Run tests + shell: bash + run: | + # We use pytest-xdist to make this run much faster. + # The downside is that we cannot use -s / --capture=no. + # + uv run --extra=dev \ + coverage run -m pytest \ + --skip-real \ + -vvv \ + --exitfirst \ + -n auto \ + . + env: + UV_PYTHON: ${{ matrix.python-version }} + + - name: Upload coverage data + uses: actions/upload-artifact@v7 + with: + name: coverage-data-windows-${{ matrix.python-version }} + path: .coverage.* + include-hidden-files: true + if-no-files-found: error + + coverage: + name: Combine & check coverage + needs: [ci-tests, skip-tests, windows-tests] + if: always() + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + python-version: '3.14' + + - uses: actions/download-artifact@v8 + with: + pattern: coverage-data-* + merge-multiple: true + + - name: Require 100% Coverage + id: coverage + run: | + uv run --extra=dev coverage combine + uv run --extra=dev coverage html --skip-covered --skip-empty + + # Report and write to summary, without failing yet. + uv run --extra=dev coverage report --format=markdown \ + >> "$GITHUB_STEP_SUMMARY" || true + + # Report again and fail if under 100%. + uv run --extra=dev coverage report + + - name: Upload HTML report if check failed + uses: actions/upload-artifact@v7 + with: + name: html-report + path: htmlcov + if: ${{ failure() }} + + # Final completion check + completion-ci: + needs: [ci-tests, skip-tests, windows-tests, coverage] + runs-on: ubuntu-latest + if: always() + steps: + - name: Check all jobs status + run: |- + if ! ${{ needs.ci-tests.result == 'success' }}; then + echo "CI tests failed" + exit 1 + fi + if ! ${{ needs.skip-tests.result == 'success' }}; then + echo "Skip tests failed" + exit 1 + fi + if ! ${{ needs.windows-tests.result == 'success' }}; then + echo "Windows tests failed" + exit 1 + fi + if ! ${{ needs.coverage.result == 'success' }}; then + echo "Coverage check failed" + exit 1 + fi diff --git a/.gitignore b/.gitignore index ec1645423..0a66b7453 100644 --- a/.gitignore +++ b/.gitignore @@ -98,8 +98,9 @@ secrets.tar # mypy .mypy_cache/ -# macOS attributes -*.DS_Store +# Ignore Mac DS_Store files +.DS_Store +**/.DS_Store # pyre .pyre/ @@ -109,3 +110,8 @@ secrets.tar # setuptools_scm src/*/_setuptools_scm_version.txt + +.claude/scheduled_tasks.lock + +# Vale styles downloaded by ``vale sync`` +styles/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..60750e61c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,477 @@ +--- +fail_fast: true + +.uv_version: &uv_version uv==0.11.7 + +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_install_hook_types: [pre-commit, pre-push] + +repos: + - repo: meta + hooks: + - id: check-useless-excludes + stages: [pre-commit] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + stages: [pre-commit] + - id: check-case-conflict + stages: [pre-commit] + - id: check-executables-have-shebangs + stages: [pre-commit] + - id: check-merge-conflict + stages: [pre-commit] + - id: check-shebang-scripts-are-executable + stages: [pre-commit] + - id: check-symlinks + stages: [pre-commit] + - id: check-json + stages: [pre-commit] + - id: check-toml + stages: [pre-commit] + - id: check-vcs-permalinks + stages: [pre-commit] + - id: check-yaml + stages: [pre-commit] + - id: end-of-file-fixer + stages: [pre-commit] + - id: file-contents-sorter + files: spelling_private_dict\.txt$ + stages: [pre-commit] + - id: trailing-whitespace + stages: [pre-commit] + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: rst-directive-colons + stages: [pre-commit] + - id: rst-inline-touching-normal + stages: [pre-commit] + - id: text-unicode-replacement-char + stages: [pre-commit] + - id: rst-backticks + + stages: [pre-commit] + - repo: https://github.com/AleksaC/hadolint-py + rev: v2.15.1 + hooks: + - id: hadolint + + stages: [pre-commit] + - repo: local + hooks: + - id: pytest-check-partition + name: pytest-check-partition + entry: >- + bash -c 'uv run --extra=dev python -c + "import json, yaml; + print(json.dumps(yaml.safe_load(open(\".github/workflows/test.yml\"))))" + | jq -r '\''.jobs["ci-tests"].strategy.matrix.ci_pattern[]'\'' + | uv run --extra=dev pytest-check-partition --patterns-stdin + --disable-plugin pytest-retry + --disable-plugin pytest_beartype_tests + --extra-arg=--disable-warnings' + stages: [pre-push] + language: python + types_or: [yaml, python] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: actionlint + name: actionlint + entry: uv run --extra=dev actionlint + language: python + pass_filenames: false + types_or: [yaml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pydocstringformatter + name: pydocstringformatter + entry: uv run --extra=dev pydocstringformatter + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shellcheck + name: shellcheck + entry: uv run --extra=dev shellcheck --shell=bash + language: python + types_or: [shell] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shellcheck-docs + name: shellcheck-docs + # We exclude SC2215 as it is a false positive for an unknown reason on Windows. + entry: uv run --extra=dev doccmd --no-write-to-file --language=shell --language=console + --command="shellcheck --shell=bash --exclude=SC2215" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shfmt + name: shfmt + entry: uv run --extra=dev shfmt --write --space-redirects --indent=4 + language: python + types_or: [shell] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shfmt-docs + name: shfmt-docs + entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt + --no-pad-file --command="shfmt --write --space-redirects --indent=4" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: mypy + name: mypy + stages: [pre-push] + entry: uv run --extra=dev -m mypy --num-workers=4 + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + # We do not use --example-workers 0 due to https://github.com/python/mypy/issues/18283 + - id: mypy-docs + name: mypy-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="mypy + --num-workers=4" + language: python + types_or: [markdown, rst] + + - id: check-manifest + name: check-manifest + stages: [pre-push] + entry: uv run --extra=dev -m check_manifest + language: python + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pyright + name: pyright + stages: [pre-push] + entry: uv run --extra=dev -m pyright . + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pyright-docs + name: pyright-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="pyright" + language: python + types_or: [markdown, rst] + + - id: pyright-verifytypes + name: pyright-verifytypes + stages: [pre-push] + entry: uv run --extra=dev -m pyright --verifytypes mock_vws + language: python + pass_filenames: false + types_or: [python] + additional_dependencies: + - *uv_version + + - id: ty + name: ty + stages: [pre-push] + entry: uv run --extra=dev ty check + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: ty-docs + name: ty-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="ty + check" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + + - id: vulture + name: vulture + entry: uv run --extra=dev -m vulture . + language: python + types_or: [python] + pass_filenames: false + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: vulture-docs + name: vulture docs + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="vulture" + language: python + types_or: [python] + pass_filenames: false + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pyroma + name: pyroma + entry: uv run --extra=dev -m pyroma --min 10 . + language: python + pass_filenames: false + types_or: [toml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: deptry + name: deptry + entry: uv run --extra=dev -m deptry src/ + language: python + pass_filenames: false + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pylint + name: pylint + entry: uv run --extra=dev -m pylint *.py src/ tests/ docs/ ci/ admin/ + language: python + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pylint-docs + name: pylint-docs + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="pylint" + language: python + stages: [manual] + types_or: [markdown, rst] + + - id: ruff-check-fix + name: Ruff check fix + entry: uv run --extra=dev -m ruff check --fix + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: ruff-check-fix-docs + name: Ruff check fix docs + entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: ruff-format-fix + name: Ruff format + entry: uv run --extra=dev -m ruff format + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: ruff-format-fix-docs + name: Ruff format docs + entry: uv run --extra=dev doccmd --language=python --no-pad-file --command="ruff + format" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: strict-kwargs-fix + name: strict-kwargs + entry: uv run --extra=dev strict-kwargs check --fix + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + require_serial: true + + - id: no-defaults + name: no-defaults + entry: uv run --extra=dev no-defaults + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + require_serial: true + + # Vale enforces prose style rules, such as banning em dashes, in + # reStructuredText and Markdown files. + # The rules come from the ``ai-tells`` package pinned in ``.vale.ini``, + # which ``vale sync`` downloads into the (gitignored) ``styles`` + # directory. + # Vale needs ``rst2html`` from Docutils on the ``PATH`` to parse + # reStructuredText. + # ``vale sync`` also runs when ``.vale.ini`` changes, so a package + # bump takes effect without waiting for the next reStructuredText + # change. + - id: vale-sync + name: vale sync + entry: uv run --extra=dev vale sync + language: python + pass_filenames: false + files: (\.(rst|md)$|^\.vale\.ini$) + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: vale + name: vale + entry: uv run --extra=dev vale + language: python + types_or: [rst, markdown] + require_serial: true + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: doc8 + name: doc8 + entry: uv run --extra=dev -m doc8 + language: python + types_or: [rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: interrogate + name: interrogate + entry: uv run --extra=dev -m interrogate + language: python + types_or: [python] + exclude_types: [executable] + stages: [pre-commit] + + - id: interrogate-docs + name: interrogate docs + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="interrogate" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pyproject-fmt-fix + name: pyproject-fmt + entry: uv run --extra=dev pyproject-fmt + language: python + types_or: [toml] + files: pyproject.toml + + stages: [pre-commit] + - id: linkcheck + name: linkcheck + entry: uv run --extra=dev sphinx-build -M linkcheck docs/source docs/build + -W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: spelling + name: spelling + entry: uv run --extra=dev sphinx-build -M spelling docs/source docs/build + -W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: docs + name: Build Documentation + entry: uv run --extra=dev sphinx-build -M html docs/source docs/build -W + language: python + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: yamlfix + name: yamlfix + entry: uv run --extra=dev yamlfix + language: python + types_or: [yaml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: zizmor + name: zizmor + entry: uv run --extra=dev zizmor --strict-collection .github + language: python + pass_filenames: false + types_or: [yaml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: sphinx-lint + name: sphinx-lint + entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long + language: python + types_or: [rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pyrefly + name: pyrefly + stages: [pre-push] + entry: uv run --extra=dev pyrefly check + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pyrefly-docs + name: pyrefly-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="pyrefly + check" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + + - id: hclfmt + name: hclfmt + entry: hclfmt -w + language: golang + types: [hcl] + additional_dependencies: [github.com/hashicorp/hcl/v2/cmd/hclfmt@v2.24.0] + stages: [pre-commit] diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..4a36aae89 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "overrides": [ + { + "files": ["*.yaml", "*.yml"], + "options": { + "singleQuote": true, + "printWidth": 100 + } + } + ] +} diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 000000000..654ec3a10 --- /dev/null +++ b/.vale.ini @@ -0,0 +1,23 @@ +StylesPath = styles +MinAlertLevel = error + +Packages = https://github.com/tbhb/vale-ai-tells/releases/download/v1.29.0/ai-tells.zip + +[*.{rst,md}] +BasedOnStyles = ai-tells + +# These rules misclassify established technical, example, or release-note prose +# in this repository. All other ai-tells rules remain enforced. +ai-tells.CataphoricForecasting = NO +ai-tells.ContrastiveFormulas = NO +ai-tells.ContrastiveNegation = NO +ai-tells.EmptyPadding = NO +ai-tells.EmptyPaddingStacked = NO +ai-tells.FigurativeLands = NO +ai-tells.FillerPhrases = NO +ai-tells.FormalRegister = NO +ai-tells.FormalTransitions = NO +ai-tells.Metacommentary = NO +ai-tells.OverusedVocabularyVerbs = NO +ai-tells.StackedAnaphora = NO +ai-tells.VerbTricolon = NO diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..2a8fa39ae --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.pylint" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..57ee5a50a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "[python]": { + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true + }, + "esbonio.sphinx.confDir": "", + "rewrap.wrappingColumn": 79, + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "pylint.importStrategy": "fromEnvironment" +} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9e529ec76..d18467cd0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,58 +1,209 @@ Changelog ========= -.. contents:: +.. towncrier release notes start -Next ----- +2026.08.14 +---------- -2019.12.27.0 +- Give ``CloudDatabase`` a ``database_id``, and reject a reco counts report request whose path names a database which the request's server keys do not belong to, as real Vuforia does. + +- Return a response, rather than raising an uncaught ``PIL.Image.DecompressionBombError``, when an image with a small file size but a huge number of pixels is given to ``POST /targets`` or ``POST /v1/query``. + As real Vuforia does, ``POST /targets`` now returns the ``ImageTooLarge`` result code for an image with more than 37748736 pixels, and the Query API applies no pixel count limit. + +- Return targets in a deterministic order from the Query API, ``GET /targets`` + and ``GET /duplicates/{target_id}``. Targets are ordered by upload date and + then by target ID, so repeated runs agree with each other. This order is not + Vuforia's match score order. + +- Document the Docker containers' configuration with the environment variable names and values which the applications actually read, starting with ``TARGET_MANAGER_BASE_URL``. + +- Build the Docker images from a committed ``uv.lock`` with a ``.dockerignore``, so that image contents are reproducible from a commit, source edits no longer invalidate the dependency layer, and repository files such as tests and documentation are no longer copied into the images. + +- Store Model Target datasets in the target manager service rather than in the VWS application. In the Docker deployment, datasets now survive a restart of the VWS container, matching how cloud databases and their targets are stored. The VWS application also no longer imports the target manager module's state: it constructs its own request rate limiter and reco counts report store. + +- Report the Docker containers as unhealthy without a traceback in the health check probe output while nothing is yet listening on the port. + +- Support ``cadDataBlob`` and ``cadDataFormat`` in Model Target dataset creation requests, and require exactly one of ``cadDataUrl`` and ``cadDataBlob`` for each model. + +- Reject Model Target Web API and OAuth2 token requests with a ``Content-Length`` header which is not an integer, matching the load balancer in front of real Vuforia. + +- Reject Model Target dataset creation requests with wrongly typed ``name``, ``targetSdk`` or ``models`` entry values. + +- Treat standard and advanced Model Target datasets as separate resources: status, download and delete requests made through the other dataset type's routes now return the unknown-dataset error rather than acting on the dataset. + +- Reject Model Target dataset creation requests with values outside the documented enumerations for the ``automaticColoring``, ``motionHint``, ``optimizeTrackingFor``, ``realisticAppearance``, ``simplify`` and ``trackingMode`` model fields. + +- Report the ``failed`` training status when downloading a Model Target dataset whose generation failed, rather than the ``not-started`` status which a still-processing dataset reports. + +- Add configurable failed Model Target dataset status responses. + +- Add configurable Model Target dataset generation warning responses. + +- Reject Model Target dataset creation requests with ``guideViewPosition`` objects which are missing ``rotation`` or ``translation``, or which have ``rotation`` or ``translation`` values that are not JSON arrays. + +- Reject Model Target dataset creation requests with ``guideViewPosition`` ``rotation`` or ``translation`` arrays which contain values that are not JSON numbers. + +- Reject Model Target bearer tokens whose JWT payload is not a JSON object. + +- Reject Model Target bearer tokens with empty or malformed JWT signatures. + +- Reject Model Target dataset creation requests with models which are missing ``name``, or which have wrongly typed ``cadDataUrl``, ``name`` or ``views`` values. + +- Reject Model Target dataset creation requests with a body which is valid JSON but not a JSON object, rather than raising an error in the mock. + +- Accept State-Based Model Target configuration and validate per-view state selections against its declared states. + +- Reject Model Target dataset creation requests with ``views`` entries which are not JSON objects, which are missing ``guideViewPosition`` or ``name``, or which have wrongly typed ``guideViewPosition`` or ``name`` values. + +- Model VWS request rate limits per endpoint with the new + ``CloudDatabase.request_rate_limits`` setting, including the limits which + Vuforia documents as ``mock_vws.request_rate_limits.DOCUMENTED_REQUEST_RATE_LIMITS``. + No request rate limit is applied by default. + +- Change the ``ProjectHasNoAPIAccess`` result code to ``ProjectHasNoApiAccess``, matching Vuforia's result codes table. + +- Add the reco counts report endpoint, and a download URL for the generated CSV report. + +- Preserve the recognition count fields, the reco rating and the reco threshold when dumping a ``CloudDatabase`` or an ``ImageTarget`` to a dictionary and loading it back. + +- Rate an image of a single color as ``0`` rather than raising an uncaught ``ZeroDivisionError``. + +- Return a 404 response from the Flask and Docker mock for a request to a path which it does not serve, and for a request to a served path with a method which that path does not serve, as real Vuforia does, rather than raising an error. + +- Reject VuMark instance generation requests whose ``instance_id`` is not a string with a ``BadRequest`` result, as real Vuforia does, and move the ``instance_id`` checks into validators shared by both mock backends. + +- Reject Model Target dataset creation requests with a body which cannot be decoded as UTF-8, rather than raising an error in the mock, and decode OAuth2 token request bodies leniently. + +2026.08.04.2 ------------ -2019.12.17.0 +- Replace the PyTorch image-quality stack with OpenCV and a lightweight BRISQUE + implementation. This reduces dependency download and installation sizes and + removes the need to configure PyTorch's CPU-only package index. + +- Allow VuMark generation requests to be configured to return ``QuotaExceeded``, ``LicenseCheckFailed``, or ``AuthorizationFailed`` responses. + +- Cloud databases with ``request_quota=0`` now return a + ``RequestQuotaReached`` response from VWS endpoints. + +- Add a mock implementation of the Model Target Web API, including OAuth2 token creation, standard and advanced dataset creation, status polling, dataset download, and deletion. + +- Improve Model Target Web API mock authentication failure responses, including + malformed and unsecured JSON Web Token headers. + +- Match real Vuforia Model Target dataset creation validation error shape, including per-request UUID, details list, and status codes (415 for unsupported media type, 400 with ``BAD_REQUEST`` validation details). + +- Match real Vuforia Model Target unknown-dataset response shape (``NOT_FOUND`` code, ``Could not find a model-view database with uuid `` message, ``userId:`` target). + Keep each Model Target dataset status response internally consistent when processing completes while the response is being generated. + +- Make synthetic Model Target dataset zip downloads byte-for-byte reproducible. + +- Match real Vuforia Model Target Web API error responses for invalid request bodies, invalid dataset creation payloads, unknown datasets, and downloads of still-processing datasets. + +- Add configurable ``TargetQuotaReached``, ``ProjectSuspended``, and + ``ProjectHasNoAPIAccess`` responses from VWS endpoints. + +- Add configurable ``TooManyRequests`` responses from VWS endpoints using the + ``CloudDatabase.requests_per_second_limit`` setting. + +- Add ``CloudQueryFailureResponse`` and the ``MockVWS.cloud_query_failure_response`` parameter for returning configurable Cloud Query failure status codes, headers, and raw bodies through the ``requests`` and ``httpx`` backends. + +2026.04.26 +---------- + + +2026.02.22.3 ------------ -2019.12.07.1 + +- ``MockVWS`` now intercepts both ``requests`` (via ``responses``) and ``httpx`` (via ``respx``) simultaneously. + ``MockVWSForHttpx`` has been removed: ``MockVWS`` handles both HTTP libraries. + +2026.02.22.2 ------------ -2019.12.07.0 + +2026.02.22.1 ------------ -2019.09.28.0 + +2026.02.22 +---------- + + +2026.02.21 +---------- + + +- Add ``VuMarkTarget`` class for VuMark template targets, alongside the renamed ``ImageTarget`` class (previously ``Target``). + ``ImageTarget`` is for image-based targets and ``VuMarkTarget`` is for VuMark template targets. + Both can be stored in a ``VuforiaDatabase``. + +2026.02.18.2 ------------ -2018.12.01.0 + +2026.02.18.1 ------------ -- Distribute type information. -2018.11.25.0 +2026.02.18 +---------- + + +2026.02.15.5 ------------ -2018.10.02.0 + +2026.02.15.4 ------------ -2018.10.01.4 + +- Add ``sleep_fn`` parameter to ``MockVWS`` for injecting a custom delay strategy, enabling deterministic and fast tests without monkey-patching. + +2026.02.15.3 ------------ -2018.10.01.3 + +- Add ``response_delay_seconds`` parameter to ``MockVWS`` for simulating slow server responses and testing timeout handling. +- Add ``response_delay_seconds`` setting to the Flask mock (``VWSSettings`` and ``VWQSettings``) for simulating slow server responses. + +2025.03.10.1 ------------ -2018.10.01.2 +2025.03.10 +---------- + +2025.02.21 +---------- + +2025.02.18 +---------- + +2024.08.30 ------------ -2018.10.01.1 +2024.07.15 ------------ -2018.10.01.0 +- Support passing data as strings. + +2024.07.02.1 ------------ -2018.09.23.1 +- Fix installation on Windows now that ``numpy`` 2.0.0 has been released. + +2024.02.16 ------------ -2018.09.23.0 +- Add a structural similarity image matcher. + +2018.12.01.0 ------------ +- Distribute type information. + 2018.09.10.0 ------------ diff --git a/LICENSE b/LICENSE index 69f733fdf..c9f18d1a3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,6 @@ -The MIT License +MIT License + +Copyright (c) 2025 Adam Dangoor Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -7,14 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in index 7dd62456b..e69de29bb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +0,0 @@ -recursive-include src/mock_vws/resources * -recursive-include src/mock_vws/_query_validators/resources * -include src/mock_vws/py.typed -include requirements.txt -include dev-requirements.txt -include pyproject.toml diff --git a/Makefile b/Makefile deleted file mode 100644 index cb1405459..000000000 --- a/Makefile +++ /dev/null @@ -1,43 +0,0 @@ -SHELL := /bin/bash -euxo pipefail - -include lint.mk - -# Treat Sphinx warnings as errors -SPHINXOPTS := -W - -.PHONY: update-secrets -update-secrets: - tar cvf secrets.tar ci_secrets/ - gpg --yes --batch --passphrase=${PASSPHRASE_FOR_VUFORIA_SECRETS} --symmetric --cipher-algo AES256 secrets.tar - -.PHONY: lint -lint: \ - black \ - check-manifest \ - doc8 \ - flake8 \ - isort \ - linkcheck \ - mypy \ - pip-extra-reqs \ - pip-missing-reqs \ - pyroma \ - shellcheck \ - spelling \ - vulture \ - pylint \ - pydocstyle \ - -.PHONY: fix-lint -fix-lint: \ - autoflake \ - fix-black \ - fix-isort - -.PHONY: docs -docs: - make -C docs clean html SPHINXOPTS=$(SPHINXOPTS) - -.PHONY: open-docs -open-docs: - python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))' diff --git a/README.rst b/README.rst index 27f400936..1a96b097e 100644 --- a/README.rst +++ b/README.rst @@ -1,52 +1,79 @@ -|Build Status| |codecov| |PyPI| |Documentation Status| +|Build Status| |PyPI| -VWS Python Mock -=============== +VWS Mock +======== -Python mock for the Vuforia Web Services (VWS) API and the Vuforia Web Query API. +.. contents:: + :local: -Installation ------------- +Mock for the Vuforia Web Services (VWS) API, the Vuforia Web Query API, and the Model Target Web API. -.. code:: sh +Mocking calls made to Vuforia +------------------------------ - pip3 install vws-python-mock +``MockVWS`` intercepts requests made with `requests`_ or `httpx`_. -This requires Python 3.8.5+. -Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. +.. code-block:: shell -Mocking Vuforia ---------------- + pip install vws-python-mock -Requests made to Vuforia can be mocked. -Using the mock redirects requests to Vuforia made with `requests `_ to an in-memory implementation. +This requires Python |minimum-python-version|\+. -.. code:: python +.. code-block:: python + + """Make a request to the Vuforia Web Services API mock.""" import requests - from mock_vws import MockVWS, VuforiaDatabase + + from mock_vws import MockVWS + from mock_vws.database import CloudDatabase with MockVWS() as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) # This will use the Vuforia mock. - requests.get('https://vws.vuforia.com/summary') + requests.get(url="https://vws.vuforia.com/summary", timeout=30) + +``MockVWS`` also intercepts `httpx`_ requests: + +.. code-block:: python + + """Make a request to the Vuforia Web Services API mock using httpx.""" + + import httpx + + from mock_vws import MockVWS + from mock_vws.database import CloudDatabase + + with MockVWS() as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + # This will use the Vuforia mock. + httpx.get(url="https://vws.vuforia.com/summary", timeout=30) By default, an exception will be raised if any requests to unmocked addresses are made. -Full Documentation +.. _requests: https://pypi.org/project/requests/ +.. _httpx: https://pypi.org/project/httpx/ + +Using Docker to mock calls to Vuforia from any language +------------------------------------------------------- + +It is possible run a Mock VWS instance using Docker containers. + +This allows you to run tests against a mock VWS instance regardless of the language or tooling you are using. + +See the `the instructions `__ for how to do this. + +Full documentation ------------------ -See the `full documentation `__. +See the `full documentation `__. This includes details on how to use the mock, options, and details of the differences between the mock and the real Vuforia Web Services. -.. |Build Status| image:: https://github.com/VWS-Python/vws-python-mock/workflows/CI/badge.svg +.. |Build Status| image:: https://github.com/VWS-Python/vws-python-mock/actions/workflows/test.yml/badge.svg?branch=main :target: https://github.com/VWS-Python/vws-python-mock/actions -.. |codecov| image:: https://codecov.io/gh/VWS-Python/vws-python-mock/branch/master/graph/badge.svg - :target: https://codecov.io/gh/VWS-Python/vws-python-mock .. |PyPI| image:: https://badge.fury.io/py/VWS-Python-Mock.svg :target: https://badge.fury.io/py/VWS-Python-Mock -.. |Documentation Status| image:: https://readthedocs.org/projects/vws-python-mock/badge/?version=latest - :target: https://vws-python-mock.readthedocs.io/en/latest/?badge=latest - :alt: Documentation Status +.. |minimum-python-version| replace:: 3.14 diff --git a/admin/__init__.py b/admin/__init__.py index 6a8f8f73b..1a76e35be 100644 --- a/admin/__init__.py +++ b/admin/__init__.py @@ -1,3 +1 @@ -""" -Admin tools. -""" +"""Admin tools.""" diff --git a/admin/create_secrets_files.py b/admin/create_secrets_files.py new file mode 100644 index 000000000..b96759997 --- /dev/null +++ b/admin/create_secrets_files.py @@ -0,0 +1,336 @@ +"""Create licenses and target databases for the tests to run against. + +See the instructions in the contributing guide in the documentation. +""" + +import datetime +import os +import sys +import textwrap +from pathlib import Path + +import vws_web_tools +from selenium.common.exceptions import TimeoutException +from selenium.webdriver.remote.webdriver import WebDriver +from vws_web_tools import ( + DatabaseDict, + ModelTargetWebAPIDict, + VuMarkDatabaseDict, +) + +VUMARK_TEMPLATE_SVG_FILE_PATH = Path(__file__).with_name( + name="vumark_template.svg", +) + + +def _create_and_get_cloud_database_details( + driver: WebDriver, + email_address: str, + password: str, + cloud_license_name: str, + cloud_database_name: str, +) -> DatabaseDict: + """Create a cloud database and get its details. + + Returns database details. + """ + vws_web_tools.log_in( + driver=driver, + email_address=email_address, + password=password, + ) + vws_web_tools.wait_for_logged_in(driver=driver) + vws_web_tools.create_license( + driver=driver, license_name=cloud_license_name + ) + + vws_web_tools.create_cloud_database( + driver=driver, + database_name=cloud_database_name, + license_name=cloud_license_name, + ) + + return vws_web_tools.get_database_details( + driver=driver, + database_name=cloud_database_name, + ) + + +def _create_and_get_vumark_details( + driver: WebDriver, + vumark_database_name: str, +) -> VuMarkDatabaseDict: + """Create a VuMark database and get its details. + + Returns VuMark database details. + """ + vws_web_tools.create_vumark_database( + driver=driver, + database_name=vumark_database_name, + ) + + return vws_web_tools.get_vumark_database_details( + driver=driver, + database_name=vumark_database_name, + ) + + +def _generate_secrets_file_content( + *, + cloud_database_details: DatabaseDict, + vumark_details: VuMarkDatabaseDict, + inactive_database_details: DatabaseDict, + inactive_vumark_details: VuMarkDatabaseDict, + vumark_target_id: str, + model_target_web_api_details: ModelTargetWebAPIDict, +) -> str: + """Generate the content of a secrets file.""" + return textwrap.dedent( + text=f"""\ + VUFORIA_TARGET_MANAGER_DATABASE_NAME={cloud_database_details["database_name"]} + VUFORIA_DATABASE_ID={cloud_database_details["database_id"]} + VUFORIA_SERVER_ACCESS_KEY={cloud_database_details["server_access_key"]} + VUFORIA_SERVER_SECRET_KEY={cloud_database_details["server_secret_key"]} + VUFORIA_CLIENT_ACCESS_KEY={cloud_database_details["client_access_key"]} + VUFORIA_CLIENT_SECRET_KEY={cloud_database_details["client_secret_key"]} + + INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME={inactive_database_details["database_name"]} + INACTIVE_VUFORIA_SERVER_ACCESS_KEY={inactive_database_details["server_access_key"]} + INACTIVE_VUFORIA_SERVER_SECRET_KEY={inactive_database_details["server_secret_key"]} + INACTIVE_VUFORIA_CLIENT_ACCESS_KEY={inactive_database_details["client_access_key"]} + INACTIVE_VUFORIA_CLIENT_SECRET_KEY={inactive_database_details["client_secret_key"]} + + VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME={vumark_details["database_name"]} + VUMARK_VUFORIA_TARGET_ID={vumark_target_id} + VUMARK_VUFORIA_SERVER_ACCESS_KEY={vumark_details["server_access_key"]} + VUMARK_VUFORIA_SERVER_SECRET_KEY={vumark_details["server_secret_key"]} + + INACTIVE_VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME={inactive_vumark_details["database_name"]} + INACTIVE_VUMARK_VUFORIA_SERVER_ACCESS_KEY={inactive_vumark_details["server_access_key"]} + INACTIVE_VUMARK_VUFORIA_SERVER_SECRET_KEY={inactive_vumark_details["server_secret_key"]} + + MODEL_TARGET_VUFORIA_CLIENT_ID={model_target_web_api_details["client_id"]} + MODEL_TARGET_VUFORIA_CLIENT_SECRET={model_target_web_api_details["client_secret"]} + MODEL_TARGET_VUFORIA_CAD_DATA_URL={model_target_web_api_details["cad_data_url"]} + """, + ) + + +def _create_and_get_vumark_target_id( + driver: WebDriver, + vumark_database_name: str, + vumark_template_name: str, +) -> str: + """Upload a VuMark template and get its target ID.""" + vws_web_tools.upload_vumark_template( + driver=driver, + database_name=vumark_database_name, + svg_file_path=VUMARK_TEMPLATE_SVG_FILE_PATH, + template_name=vumark_template_name, + width=100.0, + ) + return vws_web_tools.get_vumark_target_id( + driver=driver, + database_name=vumark_database_name, + target_name=vumark_template_name, + ) + + +def _create_and_get_inactive_database_details( + driver: WebDriver, + email_address: str, + password: str, + cloud_license_name: str, + cloud_database_name: str, +) -> DatabaseDict: + """Create a cloud database, get its details, then delete the license to + make it inactive. + """ + vws_web_tools.log_in( + driver=driver, + email_address=email_address, + password=password, + ) + vws_web_tools.wait_for_logged_in(driver=driver) + vws_web_tools.create_license( + driver=driver, license_name=cloud_license_name + ) + vws_web_tools.create_cloud_database( + driver=driver, + database_name=cloud_database_name, + license_name=cloud_license_name, + ) + cloud_database_details = vws_web_tools.get_database_details( + driver=driver, + database_name=cloud_database_name, + ) + vws_web_tools.delete_license( + driver=driver, license_name=cloud_license_name + ) + return cloud_database_details + + +def _create_and_get_inactive_vumark_details( + driver: WebDriver, + email_address: str, + password: str, + vumark_license_name: str, + vumark_database_name: str, +) -> VuMarkDatabaseDict: + """Create a VuMark database, get its details, then delete the license + to + make it inactive. + """ + vws_web_tools.log_in( + driver=driver, + email_address=email_address, + password=password, + ) + vws_web_tools.wait_for_logged_in(driver=driver) + vws_web_tools.create_license( + driver=driver, license_name=vumark_license_name + ) + vws_web_tools.create_vumark_database( + driver=driver, + database_name=vumark_database_name, + ) + vumark_database_details = vws_web_tools.get_vumark_database_details( + driver=driver, + database_name=vumark_database_name, + ) + vws_web_tools.delete_license( + driver=driver, license_name=vumark_license_name + ) + return vumark_database_details + + +def _get_model_target_web_api_details( + driver: WebDriver, + email_address: str, + password: str, +) -> ModelTargetWebAPIDict: + """Get credentials and input data for the Model Target Web API.""" + vws_web_tools.log_in( + driver=driver, + email_address=email_address, + password=password, + ) + vws_web_tools.wait_for_logged_in(driver=driver) + return vws_web_tools.get_model_target_web_api_details(driver=driver) + + +def _create_vuforia_resource_names() -> tuple[str, str, str, str]: + """Create names for Vuforia resources.""" + time = datetime.datetime.now(tz=datetime.UTC).strftime( + format="%Y-%m-%d-%H-%M-%S", + ) + return ( + f"my-cloud-license-{time}", + f"my-cloud-database-{time}", + f"my-vumark-database-{time}", + f"my-vumark-template-{time}", + ) + + +def main() -> None: + """Create secrets files.""" + email_address = os.environ["VWS_EMAIL_ADDRESS"] + password = os.environ["VWS_PASSWORD"] + new_secrets_dir = Path(os.environ["NEW_SECRETS_DIR"]).expanduser() + new_secrets_dir.mkdir(exist_ok=True) + + time = datetime.datetime.now(tz=datetime.UTC).strftime( + format="%Y-%m-%d-%H-%M-%S", + ) + inactive_driver = vws_web_tools.create_chrome_driver() + inactive_database_details = _create_and_get_inactive_database_details( + driver=inactive_driver, + email_address=email_address, + password=password, + cloud_license_name=f"my-inactive-cloud-license-{time}", + cloud_database_name=f"my-inactive-cloud-database-{time}", + ) + inactive_driver.quit() + + inactive_vumark_driver = vws_web_tools.create_chrome_driver() + inactive_vumark_details = _create_and_get_inactive_vumark_details( + driver=inactive_vumark_driver, + email_address=email_address, + password=password, + vumark_license_name=f"my-inactive-vumark-license-{time}", + vumark_database_name=f"my-inactive-vumark-database-{time}", + ) + inactive_vumark_driver.quit() + + model_target_driver = vws_web_tools.create_chrome_driver() + model_target_web_api_details = _get_model_target_web_api_details( + driver=model_target_driver, + email_address=email_address, + password=password, + ) + model_target_driver.quit() + + num_databases = 100 + required_files = [ + (new_secrets_dir / f"vuforia_secrets_{i}.env") + for i in range(num_databases) + ] + files_to_create = [file for file in required_files if not file.exists()] + driver: WebDriver | None = None + + while files_to_create: + if driver is None: + driver = vws_web_tools.create_chrome_driver() + file = files_to_create[-1] + sys.stdout.write(f"Creating database {file.name}\n") + ( + cloud_license_name, + cloud_database_name, + vumark_database_name, + vumark_template_name, + ) = _create_vuforia_resource_names() + + try: + sys.stdout.write("Creating cloud database details\n") + cloud_database_details = _create_and_get_cloud_database_details( + driver=driver, + email_address=email_address, + password=password, + cloud_license_name=cloud_license_name, + cloud_database_name=cloud_database_name, + ) + sys.stdout.write("Creating VuMark database details\n") + vumark_details = _create_and_get_vumark_details( + driver=driver, + vumark_database_name=vumark_database_name, + ) + sys.stdout.write("Creating VuMark target\n") + vumark_target_id = _create_and_get_vumark_target_id( + driver=driver, + vumark_database_name=vumark_database_name, + vumark_template_name=vumark_template_name, + ) + except TimeoutException: + sys.stderr.write("Timed out during database setup\n") + driver.quit() + driver = None + continue + + driver.quit() + driver = None + + file_contents = _generate_secrets_file_content( + cloud_database_details=cloud_database_details, + vumark_details=vumark_details, + inactive_database_details=inactive_database_details, + inactive_vumark_details=inactive_vumark_details, + vumark_target_id=vumark_target_id, + model_target_web_api_details=model_target_web_api_details, + ) + file.write_text(data=file_contents) + sys.stdout.write(f"Created database {file.name}\n") + files_to_create.pop() + + +if __name__ == "__main__": + main() diff --git a/admin/release.py b/admin/release.py deleted file mode 100644 index 1c15ac902..000000000 --- a/admin/release.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Release the next version. -""" - -import datetime -import os -import subprocess -from pathlib import Path - -from github import Github -from github.ContentFile import ContentFile -from github.Repository import Repository - - -def get_version(github_repository: Repository) -> str: - """ - Return the next version. - This is today’s date in the format ``YYYY.MM.DD.MICRO``. - ``MICRO`` refers to the number of releases created on this date, - starting from ``0``. - """ - utc_now = datetime.datetime.utcnow() - date_format = '%Y.%m.%d' - date_str = utc_now.strftime(date_format) - tag_labels = [tag.name for tag in github_repository.get_tags()] - today_tag_labels = [ - item for item in tag_labels if item.startswith(date_str) - ] - micro = int(len(today_tag_labels)) - new_version = f'{date_str}.{micro}' - return new_version - - -def update_changelog(version: str, github_repository: Repository) -> None: - """ - Add a version title to the changelog. - """ - changelog_path = Path('CHANGELOG.rst') - branch = 'master' - changelog_content_file = github_repository.get_contents( - path=str(changelog_path), - ref=branch, - ) - # ``get_contents`` can return a ``ContentFile`` or a list of - # ``ContentFile``s. - assert isinstance(changelog_content_file, ContentFile) - changelog_bytes = changelog_content_file.decoded_content - changelog_contents = changelog_bytes.decode('utf-8') - new_changelog_contents = changelog_contents.replace( - 'Next\n----', - f'Next\n----\n\n{version}\n------------', - ) - github_repository.update_file( - path=str(changelog_path), - message=f'Update for release {version}', - content=new_changelog_contents, - sha=changelog_content_file.sha, - ) - - -def build_and_upload_to_pypi() -> None: - """ - Build source and binary distributions. - """ - for args in ( - ['git', 'fetch', '--tags'], - ['git', 'merge', 'origin/master'], - ['rm', '-rf', 'build'], - ['git', 'status'], - ['python', 'setup.py', 'sdist', 'bdist_wheel'], - ['twine', 'upload', '-r', 'pypi', 'dist/*'], - ): - subprocess.run(args=args, check=True) - - -def main() -> None: - """ - Perform a release. - """ - github_token = os.environ['GITHUB_TOKEN'] - github_owner = os.environ['GITHUB_OWNER'] - github_repository_name = os.environ['GITHUB_REPOSITORY_NAME'] - github_client = Github(github_token) - github_repository = github_client.get_repo( - full_name_or_id=f'{github_owner}/{github_repository_name}', - ) - version_str = get_version(github_repository=github_repository) - update_changelog(version=version_str, github_repository=github_repository) - github_repository.create_git_tag_and_release( - tag=version_str, - tag_message='Release ' + version_str, - release_name='Release ' + version_str, - release_message='See CHANGELOG.rst', - type='commit', - object=github_repository.get_commits()[0].sha, - ) - build_and_upload_to_pypi() - - -if __name__ == '__main__': - main() diff --git a/admin/release.sh b/admin/release.sh deleted file mode 100755 index aea94b41a..000000000 --- a/admin/release.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -# Perform a release. -# See the release process documentation for details. -cd "$(mktemp -d)" -git clone git@github.com:"${GITHUB_OWNER}"/"${GITHUB_REPOSITORY_NAME}".git -cd "${GITHUB_REPOSITORY_NAME}" -virtualenv -p python3 release -source release/bin/activate -pip install --editable .[dev] -python admin/release.py diff --git a/admin/vumark_template.svg b/admin/vumark_template.svg new file mode 100644 index 000000000..4b97b6667 --- /dev/null +++ b/admin/vumark_template.svg @@ -0,0 +1 @@ + diff --git a/ci/__init__.py b/ci/__init__.py new file mode 100644 index 000000000..4b867b2bd --- /dev/null +++ b/ci/__init__.py @@ -0,0 +1 @@ +"""CI helpers.""" diff --git a/ci/custom_linters.py b/ci/custom_linters.py deleted file mode 100644 index eeead5e81..000000000 --- a/ci/custom_linters.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Custom lint tests. -""" - -import subprocess -from pathlib import Path -from typing import Dict, Set - -import pytest -import yaml - - -def _ci_patterns() -> Set[str]: - """ - Return the CI patterns given in the CI config file. - """ - repository_root = Path(__file__).parent.parent - ci_file = repository_root / '.github' / 'workflows' / 'ci.yml' - github_workflow_config = yaml.safe_load(ci_file.read_text()) - matrix = github_workflow_config['jobs']['build']['strategy']['matrix'] - ci_pattern_list = matrix['ci_pattern'] - ci_patterns = set(ci_pattern_list) - assert len(ci_pattern_list) == len(ci_patterns) - return ci_patterns - - -def _tests_from_pattern(ci_pattern: str) -> Set[str]: - """ - From a CI pattern, get all tests ``pytest`` would collect. - """ - tests: Set[str] = set([]) - args = ['pytest', '--collect-only', ci_pattern, '-q'] - result = subprocess.run(args=args, stdout=subprocess.PIPE, check=True) - output = result.stdout - for line in output.splitlines(): - if line and not line.startswith(b'no tests ran in'): - tests.add(line.decode()) - - return tests - - -def test_ci_patterns_valid() -> None: - """ - All of the CI patterns in the CI config match at least one test in the test - suite. - """ - ci_patterns = _ci_patterns() - - for ci_pattern in ci_patterns: - pattern = 'tests/mock_vws/' + ci_pattern - collect_only_result = pytest.main(['--collect-only', pattern]) - - message = f'"{ci_pattern}" does not match any tests.' - assert collect_only_result == 0, message - - -def test_tests_collected_once() -> None: - """ - Each test in the test suite is collected exactly once. - - This does not necessarily mean that they are run - they may be skipped. - """ - ci_patterns = _ci_patterns() - tests_to_patterns: Dict[str, Set[str]] = {} - for pattern in ci_patterns: - pattern = 'tests/mock_vws/' + pattern - tests = _tests_from_pattern(ci_pattern=pattern) - for test in tests: - if test in tests_to_patterns: - tests_to_patterns[test].add(pattern) - else: - tests_to_patterns[test] = set([pattern]) - - for test_name, patterns in tests_to_patterns.items(): - message = ( - f'Test "{test_name}" will be run once for each pattern in ' - f'{patterns}. ' - 'Each test should be run only once.' - ) - assert len(patterns) == 1, message - - all_tests = _tests_from_pattern(ci_pattern='tests/') - assert tests_to_patterns.keys() - all_tests == set() - assert all_tests - tests_to_patterns.keys() == set() - - -def test_init_files() -> None: - """ - ``__init__`` files exist where they should do. - - If ``__init__`` files are missing, linters may not run on all files that - they should run on. - """ - directories = (Path('src'), Path('tests')) - - for directory in directories: - files = directory.glob('**/*.py') - for python_file in files: - parent = python_file.parent - expected_init = parent / '__init__.py' - assert expected_init.exists() diff --git a/ci/set_secrets_file.py b/ci/set_secrets_file.py deleted file mode 100644 index 4506a2dbf..000000000 --- a/ci/set_secrets_file.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Move the right secrets file into place for CI. -""" - -import os -import shutil -from pathlib import Path - -import yaml - - -def move_secrets_file() -> None: - """ - Move the right secrets file to the current directory. - """ - repository_root = Path(__file__).parent.parent - ci_file = repository_root / '.github' / 'workflows' / 'ci.yml' - github_workflow_config = yaml.safe_load(ci_file.read_text()) - matrix = github_workflow_config['jobs']['build']['strategy']['matrix'] - ci_pattern_list = matrix['ci_pattern'] - current_ci_pattern = os.environ['CI_PATTERN'] - builder_number = ci_pattern_list.index(current_ci_pattern) + 1 - - secrets_dir = Path('ci_secrets') - secrets_path = secrets_dir / f'vuforia_secrets_{builder_number}.env' - shutil.copy(secrets_path, './vuforia_secrets.env') - - -if __name__ == '__main__': - move_secrets_file() diff --git a/codecov.yaml b/codecov.yaml deleted file mode 100644 index e49034f39..000000000 --- a/codecov.yaml +++ /dev/null @@ -1,6 +0,0 @@ -coverage: - status: - patch: - default: - # Require 100% test coverage. - target: 100% diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..aeb17288b --- /dev/null +++ b/conftest.py @@ -0,0 +1,32 @@ +"""Setup for Sybil.""" + +from doctest import ELLIPSIS + +import pytest +from beartype import beartype +from sybil import Sybil +from sybil.parsers.rest import ( + DocTestParser, + PythonCodeBlockParser, +) + +from tests.mock_vws.utils.retries import TRANSIENT_VWS_EXCEPTIONS + +pytest_collect_file = Sybil( + parsers=[ + DocTestParser(optionflags=ELLIPSIS), + PythonCodeBlockParser(), + ], + patterns=["*.rst", "*.py"], +).pytest() + + +@beartype +@pytest.hookimpl(optionalhook=True) +def pytest_set_filtered_exceptions() -> tuple[type[Exception], ...]: + """Return exceptions to retry on. + + This is for ``pytest-retry``. + The configuration for retries is in ``pyproject.toml``. + """ + return TRANSIENT_VWS_EXCEPTIONS diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index a52b24536..000000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -PyYAML==5.3.1 -Sphinx-Substitution-Extensions==2020.7.4.1 -Sphinx==3.2.1 -VWS-Auth-Tools==2020.5.31.0 -VWS-Test-Fixtures==2020.8.2.0 -attrs==20.1.0 # Modern attrs is required for pytest -autoflake==1.4 -black==20.8b1 -check-manifest==0.42 -doc8==0.8.1 -dodgy==0.2.1 # Look for uploaded secrets -flake8-commas==2.0.0 # Require silicon valley commas -flake8-quotes==3.2.0 # Require single quotes -flake8==3.8.3 # Lint -freezegun==0.3.15 # Freeze time in tests -isort==5.5.0 # Lint imports -keyring==21.4.0 -mypy==0.782 # Type checking -pip_check_reqs==2.1.1 -pydocstyle==5.1.1 # Lint docstrings -pyenchant==3.1.1 # Bindings for a spellchecking sytem -pygithub==1.53 -pylint==2.6.0 # Lint -pyroma==2.6 # Packaging best practices checker -pytest-cov==2.10.1 # Measure code coverage -pytest-envfiles==0.1.0 # Use files for environment variables for tests -pytest==6.0.1 # Test runners -sphinx-autodoc-typehints==1.11.0 -sphinx_paramlinks==0.4.2 -sphinxcontrib-spelling==5.3.0 -timeout-decorator==0.4.1 # Decorate functions to time out. -twine==3.2.0 -vulture==2.1 -vws-python==2020.8.21.0 diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 000000000..3bed9c2a0 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,39 @@ +variable "VERSION" { + default = "latest" +} + +group "default" { + targets = ["vws", "vwq", "target-manager"] +} + +target "_base" { + dockerfile = "src/mock_vws/_flask_server/Dockerfile" + platforms = ["linux/amd64", "linux/arm64"] +} + +target "vws" { + inherits = ["_base"] + target = "vws" + tags = [ + "ghcr.io/vws-python/vuforia-vws-mock:latest", + "ghcr.io/vws-python/vuforia-vws-mock:${VERSION}", + ] +} + +target "vwq" { + inherits = ["_base"] + target = "vwq" + tags = [ + "ghcr.io/vws-python/vuforia-vwq-mock:latest", + "ghcr.io/vws-python/vuforia-vwq-mock:${VERSION}", + ] +} + +target "target-manager" { + inherits = ["_base"] + target = "target-manager" + tags = [ + "ghcr.io/vws-python/vuforia-target-manager-mock:latest", + "ghcr.io/vws-python/vuforia-target-manager-mock:${VERSION}", + ] +} diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index aae2ad2a1..000000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = DCOSE2E -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/source/__init__.py b/docs/source/__init__.py new file mode 100644 index 000000000..535ceb2ec --- /dev/null +++ b/docs/source/__init__.py @@ -0,0 +1 @@ +"""Documentation.""" diff --git a/docs/source/basic-example.rst b/docs/source/basic-example.rst index 662c5b045..c6829a3b4 100644 --- a/docs/source/basic-example.rst +++ b/docs/source/basic-example.rst @@ -1,18 +1,23 @@ -Requests made to Vuforia can be mocked. -Using the mock redirects requests to Vuforia made with `requests `_ to an in-memory implementation. +``MockVWS`` intercepts requests to Vuforia made with `requests`_ or `httpx`_. -.. code:: python +.. code-block:: python + + """Make a request to the Vuforia Web Services API mock.""" import requests - from mock_vws import MockVWS, VuforiaDatabase + + from mock_vws import MockVWS + from mock_vws.database import CloudDatabase with MockVWS() as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) # This will use the Vuforia mock. - requests.get('https://vws.vuforia.com/summary') - + requests.get(url="https://vws.vuforia.com/summary", timeout=30) By default, an exception will be raised if any requests to unmocked addresses are made. See :ref:`mock-api-reference` for details of what can be changed and how. + +.. _requests: https://pypi.org/project/requests/ +.. _httpx: https://pypi.org/project/httpx/ diff --git a/docs/source/ci-setup.rst b/docs/source/ci-setup.rst index 0927b72df..dbe735882 100644 --- a/docs/source/ci-setup.rst +++ b/docs/source/ci-setup.rst @@ -11,21 +11,18 @@ To avoid hitting request quotas and to avoid conflicts when running multiple tes CI builds use a different credentials file depending on the build configuration. Within a workflow, this avoids conflicts. -However, there may be conflicts across workflows, as currently there is no way to prevent workflows from running in parallel. -See https://github.community/t5/GitHub-Actions/Prevent-parallel-workflows/td-p/32889. - How to set GitHub Actions secrets --------------------------------- Create environment variable files for secrets: -.. prompt:: bash +.. code-block:: console - mkdir -p ci_secrets - cp vuforia_secrets.env.example ci_secrets/vuforia_secrets_1.env - cp vuforia_secrets.env.example ci_secrets/vuforia_secrets_2.env - ... + $ mkdir -p ci_secrets + $ cp vuforia_secrets.env.example ci_secrets/vuforia_secrets_1.env + $ cp vuforia_secrets.env.example ci_secrets/vuforia_secrets_2.env + $ ... Add Vuforia credentials for different target databases to the new files in the ``ci_secrets/`` directory. Add at least as many credentials files as there are builds in the GitHub test matrix. @@ -36,9 +33,10 @@ In the GitHub repository > Settings > Secrets, add a secret with the name ``PASS Add the encrypted secrets files to the repository: -.. prompt:: bash +.. code-block:: console - PASSPHRASE_FOR_VUFORIA_SECRETS= make update-secrets - git add secrets.tar.gpg - git commit -m "Update secret archive" - git push + $ tar cvf secrets.tar ci_secrets/ + $ gpg --yes --batch --passphrase="${PASSPHRASE_FOR_VUFORIA_SECRETS}" --symmetric --cipher-algo AES256 secrets.tar + $ git add secrets.tar.gpg + $ git commit -m "Update secret archive" + $ git push diff --git a/docs/source/conf.py b/docs/source/conf.py index f86b17cbe..7ffa7efca 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,138 +1,104 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Configuration for Sphinx. -""" - -# pylint: disable=invalid-name - -import datetime -import logging -from typing import Dict, Iterable +"""Configuration for Sphinx.""" -import sphinx_autodoc_typehints -from pkg_resources import get_distribution +import importlib.metadata +from pathlib import Path -project = 'VWS-Python-Mock' -author = 'Adam Dangoor' +from packaging.specifiers import SpecifierSet +from sphinx_pyproject import SphinxConfig +_pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" +_pyproject_config = SphinxConfig( + pyproject_file=_pyproject_file, + config_overrides={"version": None}, +) -# sphinx_autodoc_typehints has a problem with dataclasses. -# See https://github.com/agronholm/sphinx-autodoc-typehints/issues/123. -# -# The logger emits a warning, which is shown in Sphinx as an error as we use -# -W to show warnings as errors. -# -# We want to ignore that error while the bug is open, and therefore we turn -# that one warning into an info message. -def _custom_warning_handler(msg: str, *args: Iterable, **kwargs: Dict) -> None: - level = logging.WARNING - if 'Cannot treat a function defined as a local function' in msg: - level = logging.INFO - - sphinx_autodoc_typehints.logger.log(level, msg, *args, **kwargs) - - -sphinx_autodoc_typehints.logger.warning = _custom_warning_handler +project = _pyproject_config.name +author = _pyproject_config.author extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.napoleon', - 'sphinx_autodoc_typehints', - 'sphinx_paramlinks', - 'sphinx-prompt', - 'sphinx_substitution_extensions', - 'sphinxcontrib.spelling', + "sphinx_copybutton", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx_paramlinks", + "sphinx_substitution_extensions", + "sphinxcontrib.spelling", + "sphinxcontrib.towncrier.ext", + "sphinxcontrib.autohttp.flask", + "sphinx_toolbox.more_autodoc.autoprotocol", ] -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' - -year = datetime.datetime.now().year -copyright = f'{year}, {author}' # pylint: disable=redefined-builtin - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# Use ``pkg_resources`` as per -# https://github.com/pypa/setuptools_scm#usage-from-sphinx. -version = get_distribution(project).version -_month, _day, _year, *_ = version.split('.') -release = f'{_month}.{_day}.{_year}' - -language = None +# Render the unreleased ``newsfragments/`` entries into +# ``docs/source/unreleased.rst`` so the Sphinx spelling, doc-build and +# link-checking gates cover the prose before it is assembled into +# CHANGELOG.rst at release time. +towncrier_draft_autoversion_mode = "draft" +towncrier_draft_include_empty = True +towncrier_draft_working_directory = f"{_pyproject_file.parent}" + +# Required by sphinx-toolbox 4.2.0rc1 for compatibility with Sphinx 9. +# See https://github.com/sphinx-toolbox/sphinx-toolbox/issues/201#issuecomment-4313483053. +autodoc_use_legacy_class_based = True + +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" + +project_copyright = f"%Y, {author}" + +# Exclude the prompt from copied code with sphinx_copybutton. +# https://sphinx-copybutton.readthedocs.io/en/latest/use.html#automatic-exclusion-of-prompts-from-the-copies. +copybutton_exclude = ".linenos, .gp" + +project_metadata = importlib.metadata.metadata(distribution_name=project) +requires_python = project_metadata["Requires-Python"] +specifiers = SpecifierSet(specifiers=requires_python) +(specifier,) = specifiers +if specifier.operator != ">=": + msg = ( + f"We only support '>=' for Requires-Python, got {specifier.operator}." + ) + raise ValueError(msg) +minimum_python_version = specifier.version + +language = "en" # The name of the syntax highlighting style to use. -pygments_style = 'sphinx' -html_theme = 'alabaster' - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# This is required for the alabaster theme -# refs: https://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - '**': [ - 'relations.html', # needs 'show_related': True theme option to display - 'searchbox.html', - ], -} +pygments_style = "sphinx" # Output file base name for HTML help builder. -htmlhelp_basename = 'VWSPYTHONMOCKdoc' -autoclass_content = 'init' +htmlhelp_basename = "VWSPYTHONMOCKdoc" +autoclass_content = "both" intersphinx_mapping = { - 'python': ('https://docs.python.org/3.8', None), - 'docker': ('https://docker-py.readthedocs.io/en/stable', None), + "python": (f"https://docs.python.org/{minimum_python_version}", None), + "docker": ("https://docker-py.readthedocs.io/en/stable", None), } nitpicky = True warning_is_error = True -nitpick_ignore = [ - ('py:exc', 'RetryError'), - # See https://bugs.python.org/issue31024 for why Sphinx cannot find this. - ('py:class', 'typing.Tuple'), - ('py:class', 'typing.Optional'), - ('py:class', '_io.BytesIO'), - ('py:class', 'docker.types.services.Mount'), - ('py:exc', 'requests.exceptions.MissingSchema'), -] +html_theme = "furo" +html_title = project html_show_copyright = False html_show_sphinx = False html_show_sourcelink = False - html_theme_options = { - 'show_powered_by': 'false', -} - -html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', - 'searchbox.html', - ], + "sidebar_hide_name": False, + "source_repository": "https://github.com/VWS-Python/vws-python-mock/", + "source_branch": "main", + "source_directory": "docs/source/", } -# Don't check anchors because many websites use #! for AJAX magic -# http://sphinx-doc.org/config.html#confval-linkcheck_anchors -linkcheck_anchors = False # Retry link checking to avoid transient network errors. linkcheck_retries = 5 -linkcheck_ignore = [ - # Requires login. - r'https://developer.vuforia.com/targetmanager', -] -spelling_word_list_filename = '../../spelling_private_dict.txt' +spelling_word_list_filename = "../../spelling_private_dict.txt" -autodoc_member_order = 'bysource' +autodoc_member_order = "bysource" rst_prolog = f""" .. |project| replace:: {project} -.. |release| replace:: {release} +.. |minimum-python-version| replace:: {minimum_python_version} .. |github-owner| replace:: VWS-Python .. |github-repository| replace:: vws-python-mock """ diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 6c40aaf3e..0ce738975 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -1,7 +1,5 @@ -Contributing -============ - -.. contents:: +Contributing to |project| +========================= Contributions to this repository must pass tests and linting. @@ -12,37 +10,39 @@ Install contribution dependencies Install Python dependencies in a virtual environment. -.. prompt:: bash +.. code-block:: console - pip install --editable .[dev] + $ pip install --editable '.[dev]' Spell checking requires ``enchant``. This can be installed on macOS, for example, with `Homebrew`_: -.. prompt:: bash +.. code-block:: console - brew install enchant + $ brew install enchant and on Ubuntu with ``apt``: -.. prompt:: bash +.. code-block:: console - apt-get install -y enchant + $ apt-get install -y enchant -Linting -------- +Install ``pre-commit`` hooks: -Run lint tools: +.. code-block:: console -.. prompt:: bash + $ prek install - make lint +Linting +------- -To fix some lint errors, run the following: +Run lint tools either by committing, or with: -.. prompt:: bash +.. code-block:: console - make fix-lint + $ prek run --all-files --hook-stage pre-commit --verbose + $ prek run --all-files --hook-stage pre-push --verbose + $ prek run --all-files --hook-stage manual --verbose .. _Homebrew: https://brew.sh @@ -51,9 +51,9 @@ Running Tests Create an environment variable file for secrets: -.. prompt:: bash +.. code-block:: console - cp vuforia_secrets.env.example vuforia_secrets.env + $ cp vuforia_secrets.env.example vuforia_secrets.env Some tests require Vuforia credentials. To run these tests, add the Vuforia credentials to the file :file:`vuforia_secrets.env`. @@ -61,9 +61,9 @@ See :ref:`connecting-to-vuforia`. Then run ``pytest``: -.. prompt:: bash +.. code-block:: console - pytest + $ pytest .. _connecting-to-vuforia: @@ -84,20 +84,52 @@ Then, add a database from the `Vuforia Target Manager`_. To find the environment variables to set in the :file:`vuforia_secrets.env` file, visit the Target Database in the `Vuforia Target Manager`_ and view the "Database Access Keys". -Two databases are necessary in order to run all the tests. +Two Cloud databases are necessary in order to run all the Cloud Target tests. One of those must be an inactive project. -To create an inactive project, delete the license key associated with a database. +The script creates the inactive project automatically by deleting its license. + +VuMark tests require one VuMark database. Targets sometimes get stuck at the "Processing" stage meaning that they cannot be deleted. When this happens, create a new target database to use for testing. -.. _Vuforia License Manager: https://developer.vuforia.com/targetmanager/licenseManager/licenseListing -.. _Vuforia Target Manager: https://developer.vuforia.com/targetmanager +To create databases without using the browser, use :file:`admin/create_secrets_files.py`: + +.. code-block:: bash + + $ export VWS_EMAIL_ADDRESS=... + $ export VWS_PASSWORD=... + $ export NEW_SECRETS_DIR=... + # You may have to run this a few times, but it is idempotent. + $ python admin/create_secrets_files.py + # Each generated file gets its own Cloud database credentials and shares + # one VuMark database credential set. + # After creating the secrets, update the encrypted archive: + $ tar cvf secrets.tar "${NEW_SECRETS_DIR}" + $ gpg \ + --yes \ + --batch \ + --passphrase="${PASSPHRASE_FOR_VUFORIA_SECRETS}" \ + --symmetric \ + --cipher-algo AES256 \ + secrets.tar + +.. _Vuforia License Manager: https://developer.vuforia.com/vui/develop/licenses +.. _Vuforia Target Manager: https://developer.vuforia.com/vui/develop/databases Skipping Some Tests ------------------- -Set either ``SKIP_MOCK`` or ``SKIP_REAL`` to ``1`` to skip tests against the mock, or tests against the real implementation, for tests which run against both. +Use the following custom ``pytest`` options to skip some tests: + +.. code-block:: text + + --skip-real Skip tests for Real Vuforia + --skip-mock Skip tests for In Memory Mock Vuforia + --skip-docker_in_memory + Skip tests for In Memory version of Docker application + --skip-docker_build_tests + Skip tests for building Docker images Documentation ------------- @@ -106,10 +138,10 @@ Documentation is built on Read the Docs. Run the following commands to build and view documentation locally: -.. prompt:: bash +.. code-block:: console - make docs - make open-docs + $ uv run --extra=dev sphinx-build -M html docs/source docs/build -W + $ python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))' Continuous Integration ---------------------- @@ -134,17 +166,13 @@ The database summary from ``GET /summary`` has multiple undocumented return fiel The database summary from ``GET /summary`` is not immediately accurate. -Some of the `Vuforia Web Services documentation `__ states that "The size of the input images must 2 MB or less". -However, the documentation page `How To Perform an Image Recognition Query`_ is more accurate: -"Maximum image size: 2.1 MPixel. 512 KiB for JPEG, 2MiB for PNG". - -The documentation page `How To Perform an Image Recognition Query`_ states that the ``Content-Type`` header must be set to ``multipart/form-data``. +The documentation page `Vuforia Query Web API`_ states that the ``Content-Type`` header must be set to ``multipart/form-data``. However, it must be set to ``multipart/form-data; boundary=`` where ```` is the boundary used when encoding the form data. -The documentation page `How To Perform an Image Recognition Query`_ states that ``Content-Type`` will be the only response header. +The documentation page `Vuforia Query Web API`_ states that ``Content-Type`` will be the only response header. This is not the case. -The documentation page `How To Perform an Image Recognition Query`_ states that 10 is the maximum allowed value of ``max_num_results``. +The documentation page `Vuforia Query Web API`_ states that 10 is the maximum allowed value of ``max_num_results``. However, the maximum allowed value is 50. A response to an invalid query may have an ``application/json`` content type but include text (not JSON) data. @@ -153,18 +181,23 @@ After deleting a target, for up to approximately 30 seconds, matching it with a A target with the name ``\uffff`` gets stuck in processing. -The documentation page `How To Perform an Image Recognition Query`_ states that "The API accepts requests with unknown data fields, and ignore the unknown fields.". +The documentation page `Vuforia Query Web API`_ states that "The API accepts requests with unknown data fields, and ignore the unknown fields.". This is not the case. -The documentation page `How To Perform an Image Recognition Query`_ states "Maximum image size: 2.1 MPixel. 512 KiB for JPEG, 2MiB for PNG". +The documentation page `Vuforia Query Web API`_ states "Maximum image size: 2.1 MPixel. 512 KiB for JPEG, 2MiB for PNG". However, JPEG images up to 2MiB are accepted. +There is no documented limit on the number of pixels in an image, but ``POST /targets`` returns ``ImageTooLarge`` for an image with more than 37748736 pixels, whatever its file size, aspect ratio or color space. +An image of a single color has a tiny file size whatever its dimensions, which is how this limit is reached. +The Query API applies no such limit. +It applies only its maximum width and height of 30000 pixels. + The ``request_count`` in a database summary is always ``0``. The documentation for the target summary report says "Note: tracking_rating and ``reco_rating`` are provided only when status = success.". However, ``reco_rating`` is never provided and ``tracking_rating`` is provided even when the status is "failed". -.. _How To Perform an Image Recognition Query: https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query +.. _Vuforia Query Web API: https://developer.vuforia.com/library/web-api/vuforia-query-web-api Release Process --------------- diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index f4c6a0e9a..3d2b22ec2 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -3,7 +3,13 @@ Differences between the mock and the real Vuforia Web Services The mock attempts to be realistic, but it was built without access to the source code of the original API. Please report any issues `here `__. -There is no attempt to make the image matching realistic. + +Image matching +-------------- + +Vuforia's image matching is proprietary and we do not intend to accurately copy it. +Instead, we aim for simple algorithms which are fast and are good enough for testing purposes. +The image matcher is configurable, using :paramref:`~mock_vws.MockVWS.match_checker`. Speed and summary accuracy -------------------------- @@ -23,30 +29,33 @@ Image quality and ratings ------------------------- Targets are assigned a rating between 0 and 5 of how good they are for tracking purposes. -In the mock this is a random number between 0 and 5. +In the mock this is calculated from the image quality, differently to how Vuforia does this. +This is customizable with the :paramref:`~mock_vws.MockVWS.target_tracking_rater` parameter. Image targets which are not suited to detection are given 'failed' statuses. The criteria for these images is not defined by the Vuforia documentation. The mock is more forgiving than the real Vuforia Web Services. Therefore, an image given a 'success' status by the mock may not be given a 'success' status by the real Vuforia Web Services. -When updating an image for a target on the real Vuforia Web Services, the rating may stay the same. -The mock changes the rating for a target to a different random number when the image is changed. +Result ordering +--------------- -Matching targets in the processing state ----------------------------------------- +The real Query API orders results by match score, with the best match first. +The mock has no match score, so it cannot reproduce that order. +Instead, the mock orders the targets it returns by upload date and then by target ID. +This makes repeated runs agree with each other, but it means that the mock's order is not a ranking. +Do not rely on the first result of a mock query being the best match. -Matching a target which is in the processing state sometimes returns a successful response with no results. -Sometimes a 500 (``INTERNAL SERVER ERROR``) response is given. -The mock always gives a 500 response. +This affects which results survive ``max_num_results``, and which result gets target data with ``include_target_data=top``. -Matching deleted targets ------------------------- +``GET /targets`` and ``GET /duplicates/{target_id}`` use the same order. +The real Vuforia Web Services do not document an order for those endpoints. + +Matching recently deleted targets +--------------------------------- -Matching a target which has been deleted returns a 500 (``INTERNAL SERVER ERROR``) response within the first few seconds. -This time frame is not consistent on the real Vuforia Web Services. -On the mock, this time frame is three seconds by default. -:py:class:`~mock_vws.MockVWS` takes a parameter :paramref:`~mock_vws.MockVWS.query_processes_deletion_seconds` to change this. +Vuforia may match targets which have been deleted within the last few seconds. +In the mock, targets are not matched after they have been deleted. Accepted date formats for the Query API --------------------------------------- @@ -56,6 +65,16 @@ The mock is strict. That is, it accepts only a few date formats, and rejects all others. If you find a date format which is accepted by the real Query API but rejected by the mock, please create a GitHub issue. +Unknown fields in Query API requests +------------------------------------ + +The `Vuforia Query Web API`_ documentation states that the API accepts requests with unknown data fields, and ignores the unknown fields. +The real Query API does not do this. +It returns a 400 (``BAD REQUEST``) response with the ``UnknownParameters`` result code when a multipart field other than ``image``, ``max_num_results`` or ``include_target_data`` is given. +The mock matches the real Query API rather than the documentation. + +.. _Vuforia Query Web API: https://developer.vuforia.com/library/vuforia-engine/web-api/vuforia-query-web-api/ + Targets stuck in processing --------------------------- @@ -91,13 +110,267 @@ Result codes ------------ Result codes are returned by requests to Vuforia to help with debugging. -See `How To Interpret VWS API Result Codes `_ for details of the available result codes. +See `VWS API Result Codes `_ for details of the available result codes. There are some result codes which the mock cannot return. These are: -* ``RequestQuotaReached`` * ``DateRangeError`` -* ``TargetQuotaReached`` -* ``ProjectSuspended`` -* ``ProjectHasNoAPIAccess`` + +Request quota exhaustion +------------------------ + +The mock returns ``RequestQuotaReached`` when a +:class:`mock_vws.database.CloudDatabase` is created with +``request_quota=0``. This behavior follows the public Vuforia documentation, +but the response has not been verified against a real database with an +exhausted quota. + +Request rate limits +------------------- + +Vuforia documents a request rate limit of 15 requests per second for VWS +endpoints in general, with 45 requests per second for +``GET /targets/{target_id}``, 10 requests per second for +``GET /duplicates/{target_id}``, and 1 request per minute for ``GET /targets``. + +The mock models these limits separately for each group of endpoints, but it +applies no limit by default. The documented numbers have not been verified +against a real database, and applying a limit of 1 request per minute to +``GET /targets`` by default would break the tests of anything which uses the +mock. Set ``request_rate_limits`` to +:data:`mock_vws.request_rate_limits.DOCUMENTED_REQUEST_RATE_LIMITS` to apply +the documented limits:: + + from mock_vws import MockVWS + from mock_vws.database import CloudDatabase + from mock_vws.request_rate_limits import DOCUMENTED_REQUEST_RATE_LIMITS + + database = CloudDatabase( + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + # A second ``GET /targets`` request within a minute returns + # ``TooManyRequests``. + ... + +``requests_per_second_limit`` remains available. It applies one limit to all +VWS endpoints together, and it is tracked separately from the per-endpoint +limits. + +Vuforia also documents that ``GET /targets`` fails for databases with more than +1 million images. The mock does not implement this, as the behavior is not +reproducible against a test account. + +Configurable Cloud Query failures +--------------------------------- + +The Vuforia Cloud Query API documents failure responses with JSON, arbitrary +content, or no body. Use +:paramref:`mock_vws.MockVWS.cloud_query_failure_response` to make every Cloud +Query request return a particular documented failure shape through the +in-process ``requests`` and ``httpx`` backends:: + + from mock_vws import CloudQueryFailureResponse, MockVWS + + failure = CloudQueryFailureResponse( + status_code=503, + headers={"Content-Type": "text/plain", "Retry-After": "10"}, + body=b"Temporarily unavailable", + ) + + with MockVWS(cloud_query_failure_response=failure): + # Cloud Query calls return the configured response. + ... + +The configured response bypasses normal Cloud Query validation and image +matching. Omitting it preserves the normal successful-query behavior. This +configuration is not supported by the Flask/Docker backend. + +Other configurable result codes +------------------------------- + +The mock also supports four other result codes which have not been verified +against real databases in the corresponding states: + +* ``TargetQuotaReached`` is returned when adding a target to a + :class:`mock_vws.database.CloudDatabase` which already contains + ``target_quota`` targets. +* ``ProjectSuspended`` is returned by VWS endpoints when a database uses the + :attr:`mock_vws.states.States.PROJECT_SUSPENDED` state. +* ``ProjectHasNoApiAccess`` is returned by VWS endpoints when a database uses + the :attr:`mock_vws.states.States.PROJECT_HAS_NO_API_ACCESS` state. + This casing comes from Vuforia's result codes table, as no response from a + real database in this state has been seen. + ``vws-python`` and ``vws-cli`` map this result code by the + ``ProjectHasNoAPIAccess`` spelling, so they do not recognize this response + until they are updated. +* ``TooManyRequests`` is returned when a + :class:`mock_vws.database.CloudDatabase` exceeds a configured request rate + limit. Set ``requests_per_second_limit`` to ``0`` to return this result code + for every VWS request. + +``Content-Length`` headers +-------------------------- + +When the given ``Content-Length`` header does not match the length of the given data, the mock server (written with Flask) will not behave as the real Vuforia Web Services behaves. + +VuMark instance images +---------------------- + +The mock returns a fixed minimal image in the requested format. +The ``instance_id`` value is not encoded into the response image. +Real Vuforia encodes the instance ID into the VuMark pattern. + +Model Target datasets +--------------------- + +The Model Target Web API mock supports OAuth2 token requests, standard and advanced dataset creation, status polling, dataset downloads, and deletion. +The generated dataset download is a small valid zip file containing request metadata, not a real Vuforia Engine Model Target dataset. +Use :paramref:`mock_vws.MockVWS.model_target_generation_failure` to make +in-process Model Target datasets finish with a ``failed`` status and an +``error`` object. The failure is returned after the configured +:paramref:`~mock_vws.MockVWS.processing_time_seconds`, so callers can test +both processing and failed states. This configuration is not supported by the +Flask/Docker backend. +Use :paramref:`mock_vws.MockVWS.model_target_generation_warning` to make +successful in-process Model Target datasets include a Vuforia-shaped +``warning`` object after processing completes. This configuration is not +supported by the Flask/Docker backend. +Model Target API routes require a three-part JSON Web Token with JSON object +header and payload parts, a non-``none`` ``alg`` value, and a non-empty +base64url-encoded signature, such as the token returned by the mock OAuth2 +route. +The mock does not verify token signatures, payload claims such as expiry, or +token revocation. + +Dataset creation request bodies which are valid JSON but not JSON objects are +reported as missing every required top-level field. +Dataset creation request bodies which cannot be decoded as UTF-8 are reported +as invalid JSON, as malformed JSON bodies are. +An OAuth2 token request body which cannot be decoded as UTF-8 is treated as one +which does not name a grant type; the real response to such a body has not been +observed. +Dataset creation requests are validated for the required top-level ``models``, +``name`` and ``targetSdk`` fields, for those fields' types, for each ``models`` +entry being a JSON object, and for the number of models. +Each model is validated for the required ``name`` field, for exactly one of +``cadDataUrl`` and ``cadDataBlob`` being given, for the types of the +``automaticColoring``, ``cadDataBlob``, ``cadDataFormat``, ``cadDataUrl``, +``motionHint``, ``name``, ``optimizeTrackingFor``, ``simplify`` and +``trackingMode`` fields, for each of the ``automaticColoring``, +``cadDataFormat``, ``motionHint``, ``optimizeTrackingFor``, ``simplify`` and +``trackingMode`` fields being one of the values which the Model Target OpenAPI +specification documents for it when the field is given, and for ``views`` +being a JSON array when it is given. The optional +``stateBasedConfigurationJsonString`` field must be a string containing a JSON +object with a ``states`` object. +The ``realisticAppearance`` model field is validated in the same way for +advanced datasets; the OpenAPI specification does not document it as a +standard dataset model field, so standard dataset creation does not validate +it. +Each ``views`` entry is validated for being a JSON object, for the required +``guideViewPosition`` and ``name`` fields, and for those fields' types. +An optional ``states`` field must be an array of strings. Each named state must +be declared by the model's ``stateBasedConfigurationJsonString``. Omitting the +field makes the view available to every configured state. +Each ``guideViewPosition`` object is validated for the required ``rotation`` +and ``translation`` fields, for those fields being JSON arrays, and for the +elements of those arrays being JSON numbers. +The mock does not validate the contents of each model further, such as whether +``cadDataUrl`` values are reachable, whether ``cadDataBlob`` values are valid +base64-encoded archives of the named ``cadDataFormat``, whether +``cadDataFormat`` is given alongside ``cadDataBlob``, the lengths of +``rotation`` and ``translation`` arrays, or ``targetSdk`` version numbers. +It also does not validate the state configuration beyond its top-level +``states`` object. + +For unknown Model Target datasets, the mock returns an error whose ``target`` is ``userId:mock``. +Real Vuforia uses ``userId:`` where the numeric portion is per-account. + +Standard and advanced datasets are separate resources. +A dataset created through the standard routes is not visible to the advanced routes, and the other way around: the mock returns the unknown-dataset error for status, download and delete requests made through the other dataset type's routes. +Real Vuforia separates these by OAuth scope as well, which the mock does not model, so a client which lacks the advanced-dataset scope may see a different error. + +Some Model Target Web API paths remain mock-only in ``tests/mock_vws/test_model_target_web_api.py::TestMockOnlyErrors``. +Downloads of still-processing datasets are mock-only because exercising the path against real Vuforia would require creating a dataset on every test run; the mock drives the processing window deterministically. +A download request for a dataset which is not ready reports the dataset's +training status. The mock reports ``not-started`` for the whole processing +window, as real Vuforia does for a dataset which was just created, and +``failed`` for a dataset whose generation failed. The name which real Vuforia +reports for a failed dataset has not been observed. +Advanced-dataset creation with more than 20 models is mock-only because the available test account lacks the advanced-dataset scope and real Vuforia rejects the request with a 403 before validating model counts. +Cross-dataset-type access is mock-only for the same reason. +State-Based Model Target creation and validation are also mock-only because the +available test account lacks the State-Based Model Target scopes. + +Reco counts reports +------------------- + +The mock does not count recognitions, so a generated reco counts report +contains only the ``target_id,reco_count`` header row, ending with a carriage +return and a line feed. +That is what real Vuforia returns for a database with no recognitions. +The mock returns the same report for the current month and the previous month. +As with real Vuforia, the report is served with a ``text/plain`` content type +rather than a CSV one. + +Real Vuforia assigns a database an ID, which the target manager shows. +The ID of a database in the mock is +:paramref:`mock_vws.database.CloudDatabase.database_id`, which defaults to a +random string, so the path of a request to this endpoint is built by reading +that attribute rather than by looking the ID up. +As real Vuforia does, the mock returns a 401 response with the +``AuthenticationFailure`` result code for a request which is signed with valid +server keys but which names any other database, including one named by its +name rather than by its ID. + +Real Vuforia returns a presigned URL for cloud storage. +The mock returns a URL served by the mock itself, without the query +parameters of a presigned URL, so the mock's URL never expires where a real +one expires after just under seven days. +The URL returned by the Flask and Docker mock is built from the +:envvar:`VWS_BASE_URL` environment variable. +The report takes :paramref:`~mock_vws.MockVWS.processing_time_seconds` +seconds to generate in the mock. +The documentation says a real report takes between a few seconds and one +hour, but a report for a database with no recognitions has been observed +ready within seconds. + +Real Vuforia names the report file after the requested month, and does so +differently for each of the two months it accepts. +A report for the current month is named for the date and the hour, such as +``2026-08-08-21.csv``, and a report for the previous month is named for the +month, such as ``2026-07.csv``. +The mock names every report after an opaque report identifier, so the +requested month cannot be recovered from the mock's URL, and two requests for +the same month never give the same URL. + +The mock's URL returns a 404 response until the report is ready, and requires +no authorization. +The lack of authorization matches real Vuforia, whose URL carries its own +signature. +The 404 has not been verified, because no request for a real report has caught +one before it was generated. + +Paths which the mock does not serve +----------------------------------- + +Real Vuforia gives an empty body with a 404 response only for a request to a +path which does not start with a served path, such as +``/some-random-endpoint``. +For any other request which it does not serve, such as ``DELETE /summary`` or +``GET /targetsfoo``, it gives an HTML "Not Found" page which names the method +and the path of the request. +The Flask and Docker mock gives an empty body for all of these. + +The ``requests`` and ``httpx`` backends mock only the paths which the mock +serves, so a request to any other path raises a connection error rather than +giving the 404 response which real Vuforia gives. + +Header cases +------------ + +The mock does not necessarily match Vuforia for all header cases. diff --git a/docs/source/docker.rst b/docs/source/docker.rst new file mode 100644 index 000000000..ab6ffd720 --- /dev/null +++ b/docs/source/docker.rst @@ -0,0 +1,193 @@ +Running a server with Docker +============================ + +It is possible run a Mock VWS instance using Docker containers. + +This allows you to run tests against a mock VWS instance regardless of the language or tooling you are using. + +Running the mock +---------------- + +There are three containers required. +One container mocks the VWS services, one container mocks the VWQ services and one container provides a shared target manager backend. + +Each of these containers run their services on port 5000. + +The VWS and VWQ containers must point to the target manager container using the :envvar:`TARGET_MANAGER_BASE_URL` variable. + +.. _creating-containers: + +Creating containers +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: console + + $ docker network create -d bridge vws-bridge-network + $ docker run \ + --detach \ + --publish 5005:5000 \ + --name vuforia-target-manager-mock \ + --network vws-bridge-network \ + ghcr.io/vws-python/vuforia-target-manager-mock + $ docker run \ + --detach \ + --publish 5006:5000 \ + -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ + --network vws-bridge-network \ + ghcr.io/vws-python/vuforia-vws-mock + $ docker run \ + --detach \ + --publish 5007:5000 \ + -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ + --network vws-bridge-network \ + ghcr.io/vws-python/vuforia-vwq-mock + + +Adding a database to the mock target manager +-------------------------------------------- + +When using Vuforia Web Services, it is necessary to create a database on the `Target Manager`_. +This is a web interface which does not have an HTTP API. + +To mimic this functionality, this mock provides a target manager container which has an HTTP API. + +To add a database, make a request to the following endpoint against the target manager container: + +.. autoflask:: mock_vws._flask_server.target_manager:TARGET_MANAGER_FLASK_APP + :endpoints: create_cloud_database + +For example, with the containers set up as in :ref:`creating-containers`, use ``curl``: + +.. code-block:: console + + $ curl --request POST \ + --header "Content-Type: application/json" \ + --data '{}' \ + '127.0.0.1:5005/cloud_databases' + { + "database_id": "ca6e48ed25a340d998905ac59747a1f8", + "database_name": "e515df24ba944f43b8f7969bc98af107", + "server_access_key": "cb1759871a504875ab5f96d6db5ff79b", + "server_secret_key": "9b8533d912ad4aa79cb61b6ee197ece2", + "client_access_key": "2d61c1d17bb94694bee77c1f1f41e5d9", + "client_secret_key": "b73f8170cf7d42728fa8ce66221ad147", + "state_name": "WORKING", + "database_type_name": "CLOUD_RECO", + "targets": [], + "request_quota": 100000, + "reco_threshold": 1000, + "current_month_recos": 0, + "previous_month_recos": 0, + "total_recos": 0, + "target_quota": 1000, + "requests_per_second_limit": null, + "request_rate_limits": null + } + +Deleting a database +------------------- + +To delete a database use the following endpoint: + +.. autoflask:: mock_vws._flask_server.target_manager:TARGET_MANAGER_FLASK_APP + :endpoints: delete_cloud_database + + +.. _Target Manager: https://developer.vuforia.com/target-manager + + +Configuration options +--------------------- + +Required configuration +^^^^^^^^^^^^^^^^^^^^^^ + +.. envvar:: TARGET_MANAGER_BASE_URL + + This is required by the VWS mock and the VWQ mock containers. + This is the base URL of the target manager container as seen from the other containers. + It must include a scheme, for example ``http://vuforia-target-manager-mock:5000``. + +Optional configuration +^^^^^^^^^^^^^^^^^^^^^^ + +VWS and Query containers +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. envvar:: RESPONSE_DELAY_SECONDS + + The number of seconds to wait before sending each response. + + Default: ``0.0`` + +Target manager container +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. envvar:: TARGET_RATER + + The rater to use for target tracking ratings. + + Options include: + + * ``brisque``: The rating is derived using the BRISQUE algorithm. + * ``perfect``: The rating is always 5. + * ``random``: The rating is random. + + Default: ``brisque`` + +Query container +~~~~~~~~~~~~~~~ + +.. envvar:: QUERY_IMAGE_MATCHER + + The matcher to use for the query endpoint. + + Options include: + + * ``exact``: The images must be exactly the same to match. + * ``structural_similarity``: The images must have a similar structural similarity to match. + + Default: ``structural_similarity`` + +VWS container +~~~~~~~~~~~~~ + +.. envvar:: PROCESSING_TIME_SECONDS + + The number of seconds to process each image for. + + Default: ``2.0`` + +.. envvar:: VWS_BASE_URL + + The base URL which clients use to reach the VWS container. + The download URL of a reco counts report is built from this URL. + + Default: ``https://vws.vuforia.com`` + +.. envvar:: DUPLICATES_IMAGE_MATCHER + + The matcher to use for the duplicates endpoint. + + Options include: + + * ``exact``: The images must be exactly the same to be duplicates. + * ``structural_similarity``: The images must have a similar structural similarity to be duplicates. + + Default: ``structural_similarity`` + +Building images from source +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: console + + $ export REPOSITORY_ROOT="$PWD" + $ export DOCKERFILE="$REPOSITORY_ROOT/src/mock_vws/_flask_server/Dockerfile" + + $ export TARGET_MANAGER_TAG=ghcr.io/vws-python/vuforia-target-manager-mock:latest + $ export VWS_TAG=ghcr.io/vws-python/vuforia-vws-mock:latest + $ export VWQ_TAG=ghcr.io/vws-python/vuforia-vwq-mock:latest + + $ docker buildx build "$REPOSITORY_ROOT" --file "$DOCKERFILE" --target target-manager --tag "$TARGET_MANAGER_TAG" + $ docker buildx build "$REPOSITORY_ROOT" --file "$DOCKERFILE" --target vws --tag "$VWS_TAG" + $ docker buildx build "$REPOSITORY_ROOT" --file "$DOCKERFILE" --target vwq --tag "$VWQ_TAG" diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index f55e3d324..17c1bce89 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -1,4 +1,9 @@ Getting started --------------- +Mocking calls made to Vuforia +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + .. include:: basic-example.rst + +.. include:: httpx-example.rst diff --git a/docs/source/httpx-example.rst b/docs/source/httpx-example.rst new file mode 100644 index 000000000..0a74e9c82 --- /dev/null +++ b/docs/source/httpx-example.rst @@ -0,0 +1,18 @@ +``MockVWS`` also intercepts requests made with `httpx`_. + +.. code-block:: python + + """Make a request to the Vuforia Web Services API mock using httpx.""" + + import httpx + + from mock_vws import MockVWS + from mock_vws.database import CloudDatabase + + with MockVWS() as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + # This will use the Vuforia mock. + httpx.get(url="https://vws.vuforia.com/summary", timeout=30) + +.. _httpx: https://pypi.org/project/httpx/ diff --git a/docs/source/index.rst b/docs/source/index.rst index 702ee43d7..3abf865f4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,11 +1,28 @@ |project| ========= -Mocking Vuforia ---------------- +Mocking calls made to Vuforia +------------------------------ + +.. code-block:: console + + $ pip install vws-python-mock + +This requires Python |minimum-python-version|\+. .. include:: basic-example.rst +.. include:: httpx-example.rst + +Using Docker to mock calls to Vuforia from any language +------------------------------------------------------- + +It is possible run a Mock VWS instance using Docker containers. + +This allows you to run tests against a mock VWS instance regardless of the language or tooling you are using. + +See :doc:`docker` for how to do this. + Reference --------- @@ -14,6 +31,7 @@ Reference installation getting-started + docker mock-api-reference differences-to-vws versioning-and-api-stability @@ -22,6 +40,7 @@ Reference .. toctree:: :hidden: + unreleased changelog release-process ci-setup diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 2d9889ab2..ce56603b2 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -1,9 +1,8 @@ Installation ------------ -.. prompt:: bash +.. code-block:: console - pip3 install vws-python-mock + $ pip install vws-python-mock -This requires Python 3.8+. -Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. +This requires Python |minimum-python-version|\+. diff --git a/docs/source/mock-api-reference.rst b/docs/source/mock-api-reference.rst index d3776274f..7354ceaff 100644 --- a/docs/source/mock-api-reference.rst +++ b/docs/source/mock-api-reference.rst @@ -7,12 +7,84 @@ API Reference :members: :undoc-members: -.. autoclass:: mock_vws.target.Target +.. autoclass:: mock_vws.MissingSchemeError :members: + :undoc-members: + +.. autoclass:: mock_vws.CloudQueryFailureResponse(*, status_code, headers={}, body=b'') + :members: + :undoc-members: + +.. autoclass:: mock_vws.VuMarkGenerationFailure + :members: + :undoc-members: + +.. autoclass:: mock_vws.ModelTargetGenerationFailure + :members: + :undoc-members: + +.. autoclass:: mock_vws.ModelTargetGenerationWarning(*, message='Warning after creating dataset', details=...) + :members: + :undoc-members: + +.. Many parts of the CloudDatabase API are used for the Flask target +.. database app, but Python users are not expected to use them. +.. Therefore, they are not documented. + +.. autoclass:: mock_vws.database.CloudDatabase + :members: + :undoc-members: + :exclude-members: to_dict, get_target, from_dict, not_deleted_targets, active_targets, inactive_targets, failed_targets, processing_targets + +.. autoclass:: mock_vws.database.VuMarkDatabase + :members: + :undoc-members: + :exclude-members: to_dict, from_dict, not_deleted_targets + +.. autoclass:: mock_vws.request_rate_limits.RequestRateLimit + :members: + :undoc-members: + :exclude-members: to_dict, from_dict + +.. autoclass:: mock_vws.request_rate_limits.RequestRateLimits + :members: + :undoc-members: + :exclude-members: to_dict, from_dict, for_endpoint + +.. autoclass:: mock_vws.request_rate_limits.RateLimitedEndpoint + :members: + :undoc-members: + +.. autodata:: mock_vws.request_rate_limits.DOCUMENTED_REQUEST_RATE_LIMITS .. autoclass:: mock_vws.states.States :members: :undoc-members: -.. autoclass:: mock_vws.database.VuforiaDatabase +.. autoclass:: mock_vws.database_type.DatabaseType + :members: + :undoc-members: + +.. autoclass:: mock_vws.target.ImageTarget + +.. autoclass:: mock_vws.target.VuMarkTarget + +Image matchers +-------------- + +.. autoprotocol:: mock_vws.image_matchers.ImageMatcher + +.. autoclass:: mock_vws.image_matchers.ExactMatcher + +.. autoclass:: mock_vws.image_matchers.StructuralSimilarityMatcher + +Target raters +------------- + +.. autoprotocol:: mock_vws.target_raters.TargetTrackingRater + +.. autoclass:: mock_vws.target_raters.RandomTargetTrackingRater + +.. autoclass:: mock_vws.target_raters.HardcodedTargetTrackingRater +.. autoclass:: mock_vws.target_raters.BrisqueTargetTrackingRater diff --git a/docs/source/release-process.rst b/docs/source/release-process.rst index 9459b62e5..db1744feb 100644 --- a/docs/source/release-process.rst +++ b/docs/source/release-process.rst @@ -7,67 +7,16 @@ Outcomes * A new ``git`` tag available to install. * A new package on PyPI. -Prerequisites -~~~~~~~~~~~~~ - -* ``python3`` on your ``PATH`` set to Python 3.8+. -* ``virtualenv``. -* Push access to this repository. -* Trust that ``master`` is ready and high enough quality for release. - Perform a Release ~~~~~~~~~~~~~~~~~ -#. Install keyring - - Make sure that `keyring `__ is available on your path. - - E.g.: - - .. prompt:: bash - - python3 -m pip install --user pipx - python3 -m pipx ensurepath - pipx install keyring - -#. Set up PyPI credentials - -Register at `PyPI `__. - -Add the following information to :file:`~/.pypirc`. - -.. code:: ini - - [distutils] - index-servers= - pypi - - [pypi] - username = - -Store your PyPI password: - -.. prompt:: bash - - keyring set https://upload.pypi.org/legacy/ - -#. Get a GitHub access token: - - Follow the `GitHub access token instructions`_ for getting an access token. - -#. Set environment variables to GitHub credentials, e.g.: - - .. prompt:: bash - - export GITHUB_TOKEN=75c72ad718d9c346c13d30ce762f121647b502414 +#. `Install GitHub CLI`_. #. Perform a release: - .. prompt:: bash + .. code-block:: console :substitutions: - export GITHUB_OWNER=|github-owner| - export GITHUB_REPOSITORY_NAME=|github-repository| - curl https://raw.githubusercontent.com/"$GITHUB_OWNER"/"$GITHUB_REPOSITORY_NAME"/master/admin/release.sh | bash + $ gh workflow run release.yml --repo "|github-owner|/|github-repository|" -.. _GitHub access token instructions: https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line/ +.. _Install GitHub CLI: https://cli.github.com/ diff --git a/docs/source/unreleased.rst b/docs/source/unreleased.rst new file mode 100644 index 000000000..22ac74723 --- /dev/null +++ b/docs/source/unreleased.rst @@ -0,0 +1,8 @@ +Unreleased changes +================== + +Changes that have landed on the main branch but are not yet part of a +tagged release. These entries are assembled into the +:doc:`changelog` when the next release is published. + +.. towncrier-draft-entries:: diff --git a/docs/towncrier_template.rst.jinja b/docs/towncrier_template.rst.jinja new file mode 100644 index 000000000..6da878330 --- /dev/null +++ b/docs/towncrier_template.rst.jinja @@ -0,0 +1,14 @@ + +{% for section_name, section in sections.items() %} +{% if section %} +{% for category, entries in section.items() %} +{% for text, _ in entries.items() %} +- {{ text }} + +{% endfor %} +{% endfor %} +{% else %} +No significant changes. + +{% endif %} +{% endfor %} diff --git a/lint.mk b/lint.mk deleted file mode 100644 index 810325e21..000000000 --- a/lint.mk +++ /dev/null @@ -1,82 +0,0 @@ -# Make commands for linting - -SHELL := /bin/bash -euxo pipefail - -.PHONY: black -black: - black --check . - -.PHONY: fix-black -fix-black: - black . - -.PHONY: mypy -mypy: - mypy *.py src/ tests/ docs/source/ admin - -.PHONY: check-manifest -check-manifest: - check-manifest . - -.PHONY: doc8 -doc8: - doc8 . - -.PHONY: flake8 -flake8: - flake8 . - -.PHONY: isort -isort: - isort --check-only . - -.PHONY: fix-isort -fix-isort: - isort . - -.PHONY: pip-extra-reqs -pip-extra-reqs: - pip-extra-reqs src/ - -.PHONY: pip-missing-reqs -pip-missing-reqs: - pip-missing-reqs src/ - -.PHONY: pylint -pylint: - pylint *.py src/ tests/ admin/ docs/ - -.PHONY: pyroma -pyroma: - pyroma --min 10 . - -.PHONY: vulture -vulture: - vulture --min-confidence 100 --exclude _vendor --exclude .eggs . - -.PHONY: linkcheck -linkcheck: - $(MAKE) -C docs/ linkcheck SPHINXOPTS=$(SPHINXOPTS) - -.PHONY: spelling -spelling: - $(MAKE) -C docs/ spelling SPHINXOPTS=$(SPHINXOPTS) - -.PHONY: shellcheck -shellcheck: - shellcheck --exclude SC2164,SC1091 */*.sh - -.PHONY: autoflake -autoflake: - autoflake \ - --in-place \ - --recursive \ - --remove-all-unused-imports \ - --remove-unused-variables \ - --expand-star-imports \ - --exclude _vendor,release \ - . - -.PHONY: pydocstyle -pydocstyle: - pydocstyle diff --git a/newsfragments/.gitkeep b/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pyproject.toml b/pyproject.toml index 7968eaf3e..50c32204b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,92 +1,531 @@ -[tool.pylint] +[build-system] +build-backend = "setuptools.build_meta" +requires = [ + "setuptools", + "setuptools-scm>=8.1.0", +] + +[project] +name = "vws-python-mock" +description = "A mock for the Vuforia Web Services (VWS) API." +readme = { file = "README.rst", content-type = "text/x-rst" } +keywords = [ + "client", + "fake", + "mock", + "vuforia", + "vws", +] +license = "MIT" +authors = [ + { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, +] +requires-python = ">=3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Pytest", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.14", +] +dynamic = [ + "version", +] +dependencies = [ + "beartype>=0.22.9", + "flask>=3.0.3", + "httpx>=0.27.0", + "numpy>=2.4.4", + "opencv-contrib-python-headless>=5.0.0.93", + "pillow>=12.2.0", + "pydantic-settings>=2.6.1", + "pyteenybrisque>=0.1.1", + "requests>=2.32.3", + "responses>=0.25.3", + "respx>=0.21.0", + "tzdata; sys_platform=='win32'", + "vws-auth-tools>=2024.7.12", + "werkzeug>=3.1.2", +] +optional-dependencies.dev = [ + "actionlint-py==1.7.12.24", + "check-manifest==0.51", + "check-wheel-contents==0.6.3", + "coverage==7.15.4", + "deptry==0.25.1", + "dirty-equals==0.11", + "doc8==2.0.0", + "doccmd==2026.7.19", + "docker==7.2.0", + "freezegun==1.5.5", + "furo==2025.12.19", + "interrogate==1.7.0", + "mypy[faster-cache]==2.3.0", + "mypy-strict-kwargs==2026.7.19.1", + "no-defaults==2.1.0", + "prek==0.4.13", + "pydocstringformatter==1.0.0", + "pydocstyle==6.3", + "pylint[spelling]==4.0.7", + "pylint-per-file-ignores==3.2.1", + "pyproject-fmt==2.27.0", + "pyrefly==1.2.0", + "pyright==1.1.411", + "pyroma==5.0.1", + "pytest==9.1.1", + "pytest-beartype-tests==2026.4.26", + "pytest-partition-check==2026.8.10.1", + "pytest-retry==1.7.0", + "pytest-xdist==3.8.0", + "pyyaml==6.0.3", + "requests-mock-flask==2026.4.2", + "ruff==0.16.2", + # We add shellcheck-py not only for shell scripts and shell code blocks, + # but also because having it installed means that ``actionlint-py`` will + # use it to lint shell commands in GitHub workflow files. + "shellcheck-py==0.11.0.1", + "shfmt-py==4.0.0", + "sphinx==9.1.0", + "sphinx-copybutton==0.5.2", + "sphinx-lint==1.0.2", + "sphinx-paramlinks==0.6", + "sphinx-pyproject==0.3.0", + "sphinx-substitution-extensions==2026.8.13", + "sphinx-toolbox==4.3.0", + "sphinxcontrib-httpdomain==2.0.0", + "sphinxcontrib-spelling==8.0.2", + # ``sphinxcontrib-towncrier`` renders unreleased news fragments + # into docs/source/unreleased.rst during Sphinx builds. + "sphinxcontrib-towncrier==0.5.0a0", + "strict-kwargs==2026.7.24", + "sybil==10.1.0", + "tenacity==9.1.4", + "towncrier==25.8.0", + "ty==0.0.70", + "types-docker==7.2.0.20260811", + "types-pyyaml==6.0.12.20260724", + "types-requests==2.33.0.20260712", + "urllib3==2.7.0", + "vale==3.13.0.0", + "vulture==2.16", + "vws-python==2026.2.25.1", + "vws-test-fixtures==2023.3.5", + "vws-web-tools==2026.8.7", + "yamlfix==1.19.1", + "zizmor==1.29.0", +] +optional-dependencies.release = [ "check-wheel-contents==0.6.3", "towncrier==25.8.0" ] +urls.Documentation = "https://vws-python.github.io/vws-python-mock/" +urls.Source = "https://github.com/VWS-Python/vws-python-mock" + +[dependency-groups] +dev = [] - [tool.pylint.'MASTER'] - - # Pickle collected data for later comparisons. - persistent = true - - # Use multiple processes to speed up Pylint. - jobs = 0 - - # List of plugins (as comma separated values of python modules names) to load, - # usually to register additional checkers. - load-plugins = ['pylint.extensions.docparams'] - - # Allow loading of arbitrary C extensions. Extensions are imported into the - # active Python interpreter and may run arbitrary code. - unsafe-load-any-extension = false - - [tool.pylint.'MESSAGES CONTROL'] - - # Enable the message, report, category or checker with the given id(s). You can - # either give multiple identifier separated by comma (,) or put this option - # multiple time (only on the command line, not in the configuration file where - # it should appear only once). See also the "--disable" option for examples. - enable = [ - 'spelling', - 'useless-suppression', - ] - - # Disable the message, report, category or checker with the given id(s). You - # can either give multiple identifiers separated by comma (,) or put this - # option multiple times (only on the command line, not in the configuration - # file where it should appear only once).You can also use "--disable=all" to - # disable everything first and then reenable specific checks. For example, if - # you want to run only the similarities checker, you can use "--disable=all - # --enable=similarities". If you want to run only the classes checker, but have - # no Warning level messages displayed, use"--disable=all --enable=classes - # --disable=W" - - disable = [ - # Tests need `self` to be in a class but do not use it. - 'no-self-use', - 'too-few-public-methods', - 'too-many-locals', - 'too-many-arguments', - 'too-many-instance-attributes', - 'too-many-return-statements', - 'too-many-lines', - 'locally-disabled', - # Let flake8 handle long lines - 'line-too-long', - # Let flake8 handle unused imports - 'unused-import', - # Let isort deal with sorting - 'ungrouped-imports', - # We don't need everything to be documented because of mypy - 'missing-type-doc', - 'missing-returns-doc', - 'missing-return-type-doc', - # Let auto-formatters deal with this - 'bad-continuation', - # Let auto-formatters deal with this - 'bad-whitespace', - # Too difficult to please - 'duplicate-code', - # Let isort handle imports - 'wrong-import-order', - ] - - [tool.pylint.'FORMAT'] - - # Allow the body of an if to be on the same line as the test if there is no - # else. - single-line-if-stmt = false - - [tool.pylint.'SPELLING'] - - # Spelling dictionary name. Available dictionaries: none. To make it working - # install python-enchant package. - spelling-dict = 'en_US' - - # A path to a file that contains private dictionary; one word per line. - spelling-private-dict-file = 'spelling_private_dict.txt' - - # Tells whether to store unknown words to indicated private dictionary in - # --spelling-private-dict-file option instead of raising a message. - spelling-store-unknown-words = 'no' - -[tool.black] +[tool.setuptools] +packages.find.where = [ + "src", +] +package-data.mock_vws = [ + "py.typed", +] +zip-safe = false +[tool.setuptools_scm] +# This keeps the start of the version the same as the last release. +# This is useful for our documentation to include e.g. binary links +# to the latest released binary. +# +# Code to match this is in ``conf.py``. +version_scheme = "post-release" +# We use a fallback version like +# https://github.com/pypa/setuptools_scm/issues/77 so that we do not +# error in the Docker build stage of the release pipeline. +# +# This must be a PEP 440 compliant version. +fallback_version = "0.0.0" + +[tool.ruff] line-length = 79 -skip-string-normalization = true +lint.select = [ + "ALL", +] +lint.ignore = [ + # Ruff warns that this conflicts with the formatter. + "COM812", + # Copyright headers are not required. + "CPY001", + # Allow our chosen docstring line-style - pydocstringformatter handles formatting + # but doesn't enforce D205 (blank line after summary) or D212 (summary on first line). + "D205", + "D212", + "D415", + # Ruff warns that this conflicts with the formatter. + "ISC001", + # Ignore 'too-many-*' errors as they seem to get in the way more than + # helping. + "PLR0913", + # Beartype requires imports to be available at runtime, not just for type + # checking. See https://github.com/beartype/beartype/discussions/594 + # for when beartype will support `if TYPE_CHECKING` imports. + "TC001", + "TC002", + "TC003", +] +lint.per-file-ignores."doccmd_*.py" = [ + # Allow our chosen docstring line-style - pydocstringformatter handles + # formatting but docstrings in docs may not match this style. + "D200", + # Allow asserts in docs. + "S101", +] +lint.per-file-ignores."tests/**" = [ + # Allow asserts in tests. + "S101", + # Allow possible hardcoded passwords in tests. + "S105", + "S106", +] +# Do not automatically remove commented out code. +# We comment out code during development, and with VSCode auto-save, this code +# is sometimes annoyingly removed. +lint.unfixable = [ + "ERA001", +] +lint.external = [ "NOD" ] +lint.flake8-tidy-imports.banned-api."typing.cast".msg = "typing.cast is banned: use explicit type narrowing or a typed variable instead." +lint.pydocstyle.convention = "google" + +[tool.pylint] +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +"MESSAGES CONTROL".disable = [ + # Too difficult to please + "duplicate-code", + # Let ruff handle long lines + "line-too-long", + "locally-disabled", + "missing-return-type-doc", + # We don't need everything to be documented because of mypy + "missing-type-doc", + # Style issues that we can deal with ourselves + "too-few-public-methods", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + # Let ruff deal with sorting + "ungrouped-imports", + # Let ruff handle unused imports + "unused-import", + # Let ruff handle imports + "wrong-import-order", +] +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +"MESSAGES CONTROL".enable = [ + "bad-inline-option", + "deprecated-pragma", + "file-ignored", + "spelling", + "use-symbolic-message-instead", + "useless-suppression", +] +DEPRECATED_BUILTINS.bad-functions = [ + # Use Pylint until Ruff can ban bare builtin calls, or until custom rules + # make this removable: + # https://github.com/astral-sh/ruff/issues/10079 + # https://github.com/astral-sh/ruff/issues/970 + "filter", + "getattr", + "hasattr", + "map", + "setattr", +] +# Allow the body of an if to be on the same line as the test if there is no +# else. +FORMAT.single-line-if-stmt = false +# Return non-zero exit code if useless-suppression is emitted. +MAIN.fail-on = [ + "useless-suppression", +] +# Use multiple processes to speed up Pylint. +MASTER.jobs = 0 +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +# See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. +# We do not use the plugins: +# - pylint.extensions.code_style +# - pylint.extensions.magic_value +# - pylint.extensions.while_used +# as they seemed to get in the way. +MASTER.load-plugins = [ + "pylint.extensions.bad_builtin", + "pylint.extensions.comparison_placement", + "pylint.extensions.consider_refactoring_into_while_condition", + "pylint.extensions.docparams", + "pylint.extensions.dunder", + "pylint.extensions.eq_without_hash", + "pylint.extensions.for_any_all", + "pylint.extensions.mccabe", + "pylint.extensions.no_self_use", + "pylint.extensions.overlapping_exceptions", + "pylint.extensions.private_import", + "pylint.extensions.redefined_loop_name", + "pylint.extensions.redefined_variable_type", + "pylint.extensions.set_membership", + "pylint.extensions.typing", + "pylint_per_file_ignores", +] +# We ignore invalid names because: +# - We want to use generated module names, which may not be valid, but are never seen. +# - We want to use global variables in documentation, which may not be uppercase +MASTER.per-file-ignores = [ + "docs/source/conf.py:invalid-name", + "docs/source/doccmd_*.py:invalid-name", + "doccmd_README_rst_*.py:invalid-name", +] +# Pickle collected data for later comparisons. +MASTER.persistent = true +MASTER.unsafe-load-any-extension = false +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +SPELLING.spelling-dict = "en_US" +# A path to a file that contains private dictionary; one word per line. +SPELLING.spelling-private-dict-file = "spelling_private_dict.txt" +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +SPELLING.spelling-store-unknown-words = "no" + +[tool.interrogate] +fail-under = 100 +verbose = 2 +omit-covered-files = true + +[tool.check-manifest] +ignore = [ + "*.enc", + ".checkmake-config.ini", + ".dockerignore", + ".git_archival.txt", + ".prettierrc", + ".vale.ini", + ".yamlfmt", + "admin/**", + "CHANGELOG.rst", + "ci", + "ci/**", + "CODE_OF_CONDUCT.rst", + "CONTRIBUTING.rst", + "docs", + "docs/**", + "LICENSE", + "lint.mk", + "Makefile", + "newsfragments", + "newsfragments/**", + "secrets.tar.gpg", + "spelling_private_dict.txt", + "src/mock_vws/_flask_server/Dockerfile", + "tests", + "tests/**", + "uv.lock", + "vuforia_secrets.env.example", +] + +[tool.deptry] +per_rule_ignores.DEP002 = [ + # tzdata is needed on Windows for zoneinfo to work. + # See https://docs.python.org/3/library/zoneinfo.html#data-sources. + "tzdata", +] +optional_dependencies_dev_groups = [ + "dev", + "release", +] + +[tool.vulture] +# Duplicate some of .gitignore +exclude = [ ".venv" ] +# Ideally we would limit the paths to the source code where we want to ignore names, +# but Vulture does not enable this. +ignore_names = [ + # Sphinx + "autoclass_content", + "autoclass_content", + "autodoc_member_order", + "autodoc_use_legacy_class_based", + # Used in TYPE_CHECKING for type hints + "CloudDatabaseDict", + "copybutton_exclude", + "DatabaseDict", + # Too difficult to test (see notes in the code) + "DATE_RANGE_ERROR", + "extensions", + # pytest fixtures - we name fixtures like this for this purpose + "fixture_*", + "html_show_copyright", + "html_show_sourcelink", + "html_show_sphinx", + "html_theme", + "html_theme_options", + "html_title", + "htmlhelp_basename", + "intersphinx_mapping", + "language", + "linkcheck_ignore", + "linkcheck_retries", + "master_doc", + # pydantic-settings + "model_config", + "nitpicky", + "project_copyright", + "pygments_style", + "pytest_addoption", + # pytest configuration + "pytest_collect_file", + "pytest_collection_modifyitems", + "pytest_plugins", + "pytest_set_filtered_exceptions", + "REQUEST_QUOTA_REACHED", + "rst_prolog", + "source_suffix", + "spelling_word_list_filename", + "templates_path", + "towncrier_draft_autoversion_mode", + "towncrier_draft_include_empty", + "towncrier_draft_working_directory", + "VuMarkDatabaseDict", + "VuMarkTargetDict", + "warning_is_error", +] +ignore_decorators = [ + "@*APP.after_request", + "@*APP.before_request", + "@*APP.errorhandler", + # Flask + "@*APP.route", + "@pytest.fixture", + "@route", +] + +[tool.pyproject-fmt] +indent = 4 +keep_full_version = true +max_supported_python = "3.14" + +[tool.mypy] +files = [ "." ] +exclude = [ "build" ] +exclude_gitignore = true +follow_untyped_imports = true +strict = true +plugins = [ + "pydantic.mypy", + "mypy_strict_kwargs", +] + +[tool.pyrefly] +search_path = [ + ".", + "src", +] +errors.non-exhaustive-match = "error" + +[tool.pyright] +typeCheckingMode = "strict" +enableTypeIgnoreComments = false +reportUnnecessaryTypeIgnoreComment = true + +[tool.pytest] +addopts = [ + "--strict-markers", +] +cumulative_timing = false +log_cli = true +markers = [ + "requires_docker_build", +] +# Options for pytest-retry. +retries = "10" +retry_delay = "10" +xfail_strict = true + +[tool.coverage] +run.branch = true +run.parallel = true +run.patch = [ "subprocess" ] +run.relative_files = true +run.source = [ "ci/", "src/", "tests/" ] +report.exclude_also = [ + "case _ as unreachable:\n\\s*assert_never\\(", + "class .*\\bProtocol\\):", + "if TYPE_CHECKING:", +] +report.fail_under = 100 +report.show_missing = true + +[tool.towncrier] +# The changelog and the per-release GitHub release notes are both built +# from news fragments under ``newsfragments/``. The release workflow +# runs ``towncrier build`` to assemble them; contributors add one +# fragment file per user-facing change. +directory = "newsfragments" +filename = "CHANGELOG.rst" +# Custom template so an assembled version reproduces the historical +# style exactly: a bare ```` heading (no project name, no +# date) followed by a flat bullet list with no per-type sub-headings. +template = "docs/towncrier_template.rst.jinja" +title_format = "{version}" +issue_format = "#{issue}" +# ``title_format`` underline first, then any nested headings. A bare +# version such as ``2026.05.18`` underlined with ``-`` matches every +# pre-towncrier entry in CHANGELOG.rst. +underlines = [ "-", "~", "^" ] +type = [ + # A single, unnamed fragment type keeps the assembled output as one + # flat bullet list, matching the historical changelog (which never + # grouped entries under "Features"/"Bugfixes"/... sub-headings). + { directory = "change", name = "", showcontent = true }, +] + +[tool.pydocstringformatter] +write = true +split-summary-body = false +max-line-length = 75 +linewrap-full-docstring = true + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.doc8] +max_line_length = 2000 +ignore_path = [ + "./.eggs", + "./docs/build", + "./docs/build/spelling/output.txt", + "./node_modules", + "./src/*.egg-info/", + "./src/*/_setuptools_scm_version.txt", +] + +[tool.yamlfix] +section_whitelines = 1 +whitelines = 1 + +[tool.no_defaults] +private_only = true +per_file_enforcement."tests/**" = "all" diff --git a/readthedocs.yaml b/readthedocs.yaml deleted file mode 100644 index dd0105691..000000000 --- a/readthedocs.yaml +++ /dev/null @@ -1,18 +0,0 @@ -version: 2 - -# We do this because at the time of writing we need "image: latest" for Python -# 3.8. -build: - image: latest - -python: - install: - - method: pip - path: . - extra_requirements: - - dev - version: 3.8 - -sphinx: - builder: html - fail_on_warning: true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 57287aae3..000000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -backports.zoneinfo==0.2.1 -Pillow==7.2.0 -requests-mock==1.8.0 -requests==2.24.0 -wrapt==1.12.1 diff --git a/secrets.tar.gpg b/secrets.tar.gpg index ef25b999e..3dd97f72b 100644 Binary files a/secrets.tar.gpg and b/secrets.tar.gpg differ diff --git a/setup-requirements.txt b/setup-requirements.txt deleted file mode 100644 index 78f0bb68e..000000000 --- a/setup-requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -setuptools_scm==4.1.2 -setuptools-scm-git-archive==1.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b038e8b0f..000000000 --- a/setup.cfg +++ /dev/null @@ -1,140 +0,0 @@ -[check-manifest] -ignore = - *.enc - *.gpg - .coveragerc - .isort.cfg - .git_archival.txt - .markdownlint.json - .pydocstyle - .readthedocs.yml - readthedocs.yaml - .remarkrc - .style.yapf - .travis.yml - admin - admin/* - CHANGELOG.rst - CODE_OF_CONDUCT.rst - CONTRIBUTING.rst - LICENSE - Makefile - ci - ci/** - codecov.yaml - dev-requirements.txt - doc8.ini - docs - docs/** - mypy.ini - pylintrc - pytest.ini - lint.mk - requirements.txt - setup-requirements.txt - spelling_private_dict.txt - tests - tests-pylintrc - tests/** - vuforia_secrets.env.example - -[flake8] -exclude=./.eggs, - ./build/, - -[pydocstyle] -# No summary lines -# - D200 -# - D205 -# - D400 -# - D415 -# We don't want blank lines before class docstrings -# - D203 -# We don't need docstrings to start at the first line -# - D212 -# Allow blank lines after function docstrings -# - D202 -# We don't care about the imperative mood -# - D401 -# Section names do not need to end in newlines -# - D406 -# Section names do not need dashed underlines -# - D407 -# No blank line is needed after the last section -ignore = D200,D202,D203,D205,D212,D400,D401,D406,D407,D413,D415 - -[mypy] -check_untyped_defs = True -disallow_incomplete_defs = True -disallow_subclassing_any = True -disallow_untyped_calls = True -disallow_untyped_decorators = False -disallow_untyped_defs = True -follow_imports = silent -ignore_missing_imports = True -no_implicit_optional = True -strict_equality = True -strict_optional = True -warn_no_return = True -warn_redundant_casts = True -warn_return_any = True -warn_unused_configs = True -warn_unused_ignores = True - -[tool:pytest] -env_files = - ./vuforia_secrets.env -xfail_strict=true -log_cli=true - -[doc8] -max-line-length = 2000 -ignore-path = ./src/*.egg-info/SOURCES.txt,./docs/build,./.eggs,./src/*/_setuptools_scm_version.txt - -[isort] -multi_line_output=3 -include_trailing_comma=true -skip=_vendor, - .eggs, - setup.py, - -[coverage:run] -branch = True -omit = - *_vendor* - -[metadata] -name = VWS Python Mock -description = A mock for the Vuforia Web Services (VWS) API. -long_description = file: README.rst -long_description_content_type = text/x-rst -keywords = vuforia mock fake client -license = MIT License -license_file = LICENSE -classifiers = - Operating System :: POSIX - Environment :: Web Environment - Programming Language :: Python :: 3.8 - License :: OSI Approved :: MIT License - Development Status :: 5 - Production/Stable -url = https://vws-python-mock.readthedocs.io -author = Adam Dangoor -author_email = adamdangoor@gmail.com - -[options] -zip_safe = False -include_package_data = True -# Avoid dependency links because they are not supported by Read The Docs. -# -# Also, they require users to use ``--process-dependency-links``. -dependency_links = -package_dir= - =src -packages=find: - -[options.packages.find] -where=src - -[options.package_data] -mock_vws = - py.typed diff --git a/setup.py b/setup.py deleted file mode 100644 index 5806738ff..000000000 --- a/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Setup script for VWS Python Mock, a mock of Vuforia's Web Services APIs. -""" - -from pathlib import Path -from typing import List - -from setuptools import setup - - -def _get_dependencies(requirements_file: Path) -> List[str]: - """ - Return requirements from a requirements file. - - This expects a requirements file with no ``--find-links`` lines. - """ - lines = requirements_file.read_text().strip().split('\n') - return [line for line in lines if not line.startswith('#')] - - -INSTALL_REQUIRES = _get_dependencies( - requirements_file=Path('requirements.txt'), -) - -DEV_REQUIRES = _get_dependencies( - requirements_file=Path('dev-requirements.txt'), -) - -SETUP_REQUIRES = _get_dependencies( - requirements_file=Path('setup-requirements.txt'), -) - -setup( - use_scm_version=True, - setup_requires=SETUP_REQUIRES, - install_requires=INSTALL_REQUIRES, - extras_require={'dev': DEV_REQUIRES}, -) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 0bbf08abe..2924c70a3 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,14 +1,22 @@ +CSV KiB MPixel MiB MissingSchema +OAuth +Reco Ubuntu +VuMark admin +another's api args ascii auth +backend backends +balancer +beartype binascii bool boolean @@ -16,6 +24,7 @@ bytesio changelog chunked cmyk +config connectionerror customizable dataclass @@ -26,17 +35,22 @@ dev dict docstring docstrings +eof +exc filename foo formdata +fp github greyscale gzip +hardcoded hexdigits hmac html http https +httpx iff io issuecomment @@ -50,33 +64,62 @@ linters linting login macOS +matcher +matchers mb metadata mib mockvws multipart +mypy +nat noqa +outerboundary +overridable pdict plugins png pragma +presigned processable +pyrefly +pyright pytest readme readthedocs +reco recognitions refactoring regex reimplementation +reportAssignmentType +reportAttributeAccessIssue +reportGeneralTypeIssues +reportMissingTypeStubs +reportPrivateImportUsage +reportUnknownArgumentType +reportUnknownMemberType +reportUnknownVariableType repr +reqheader +reqjson +reqjsonarr +resheader +resjson +resjsonarr +respx rfc rgb str +stringify +subprocess timestamp todo travis txt +unlinks unmocked +untagged url usefixtures validator diff --git a/src/mock_vws/__init__.py b/src/mock_vws/__init__.py index d42bcdcc8..0b1072981 100644 --- a/src/mock_vws/__init__.py +++ b/src/mock_vws/__init__.py @@ -1,9 +1,19 @@ -""" -Tools for using a fake implementation of Vuforia. -""" +"""Tools for using a fake implementation of Vuforia.""" -from mock_vws._requests_mock_server.decorators import MockVWS +from mock_vws._mock_common import MissingSchemeError +from mock_vws.cloud_query import CloudQueryFailureResponse +from mock_vws.decorators import MockVWS +from mock_vws.model_target import ( + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) +from mock_vws.vumark import VuMarkGenerationFailure __all__ = [ - 'MockVWS', + "CloudQueryFailureResponse", + "MissingSchemeError", + "MockVWS", + "ModelTargetGenerationFailure", + "ModelTargetGenerationWarning", + "VuMarkGenerationFailure", ] diff --git a/src/mock_vws/_base64_decoding.py b/src/mock_vws/_base64_decoding.py index 91a41da83..d6ed379b1 100644 --- a/src/mock_vws/_base64_decoding.py +++ b/src/mock_vws/_base64_decoding.py @@ -1,36 +1,35 @@ -""" -Helpers for handling Base64 like Vuforia does. -""" +"""Helpers for handling Base64 like Vuforia does.""" import base64 import binascii import string +from beartype import beartype + +@beartype def decode_base64(encoded_data: str) -> bytes: - """ - Decode base64 somewhat like Vuforia does. + """Decode base64 somewhat like Vuforia does. Raises: binascii.Error: Vuforia would consider this encoded data as an - "UNPROCESSABLE_ENTITY". + "UNPROCESSABLE_ENTITY". Returns: The given data, decoded as base64. """ - acceptable_characters = string.ascii_letters + string.digits + '+/=' + acceptable_characters = string.ascii_letters + string.digits + "+/=" for character in encoded_data: if character not in acceptable_characters: - raise binascii.Error() - - if len(encoded_data) % 4 == 0: - decoded = base64.b64decode(encoded_data) - elif len(encoded_data) % 4 == 1: - decoded = base64.b64decode(encoded_data[:-1]) - elif len(encoded_data) % 4 == 2: - decoded = base64.b64decode(encoded_data + '==') - else: - assert len(encoded_data) % 4 == 3 - decoded = base64.b64decode(encoded_data + '=') + raise binascii.Error - return decoded + mod_four_result_to_modified_encoded_data = { + 0: encoded_data, + 1: encoded_data[:-1], + 2: f"{encoded_data}==", + 3: f"{encoded_data}=", + } + modified_encoded_data = mod_four_result_to_modified_encoded_data[ + len(encoded_data) % 4 + ] + return base64.b64decode(s=modified_encoded_data) diff --git a/src/mock_vws/_constants.py b/src/mock_vws/_constants.py index cbba98ffa..906cd4c7b 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -1,49 +1,87 @@ -""" -Constants used to make the VWS mock. -""" +"""Constants used to make the VWS mock.""" -from enum import Enum +from enum import Enum, unique +from beartype import beartype +VUMARK_PNG = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00" + b"\x01\x08\x04\x00\x00\x00\xb5\x1c\x0c\x02\x00\x00\x00\x0bIDATx\xdac" + b"\xfc\xff\x1f\x00\x03\x03\x02\x00\xee\xd9\x97\xa9\x00\x00\x00\x00IEND" + b"\xaeB`\x82" +) + +VUMARK_SVG = ( + b'' +) + +VUMARK_PDF = ( + b"%PDF-1.4\n" + b"1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"xref\n0 4\n" + b"0000000000 65535 f \n" + b"trailer<>\n" + b"startxref\n9\n%%EOF" +) + + +@beartype +@unique class ResultCodes(Enum): - """ - Constants representing various VWS result codes. + """Constants representing various VWS result codes. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Interperete-VWS-API-Result-Codes + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. Some codes here are not documented in the above link. """ - SUCCESS = 'Success' - TARGET_CREATED = 'TargetCreated' - AUTHENTICATION_FAILURE = 'AuthenticationFailure' - REQUEST_TIME_TOO_SKEWED = 'RequestTimeTooSkewed' - TARGET_NAME_EXIST = 'TargetNameExist' - UNKNOWN_TARGET = 'UnknownTarget' - BAD_IMAGE = 'BadImage' - IMAGE_TOO_LARGE = 'ImageTooLarge' - METADATA_TOO_LARGE = 'MetadataTooLarge' + SUCCESS = "Success" + TARGET_CREATED = "TargetCreated" + AUTHENTICATION_FAILURE = "AuthenticationFailure" + REQUEST_TIME_TOO_SKEWED = "RequestTimeTooSkewed" + TARGET_NAME_EXIST = "TargetNameExist" + UNKNOWN_TARGET = "UnknownTarget" + BAD_IMAGE = "BadImage" + IMAGE_TOO_LARGE = "ImageTooLarge" + METADATA_TOO_LARGE = "MetadataTooLarge" # The documentation says "Start date is after the end date" but, at the # time of writing, I do not know how to trigger that, therefore this is not # tested. - DATE_RANGE_ERROR = 'DateRangeError' - FAIL = 'Fail' - TARGET_STATUS_PROCESSING = 'TargetStatusProcessing' - REQUEST_QUOTA_REACHED = 'RequestQuotaReached' - TARGET_STATUS_NOT_SUCCESS = 'TargetStatusNotSuccess' - PROJECT_INACTIVE = 'ProjectInactive' - INACTIVE_PROJECT = 'InactiveProject' + DATE_RANGE_ERROR = "DateRangeError" + FAIL = "Fail" + TARGET_STATUS_PROCESSING = "TargetStatusProcessing" + # This is tested only against the mock. We do not deliberately exhaust the + # real test database's quota because that would stop the verified-fake test + # suite from using it. + REQUEST_QUOTA_REACHED = "RequestQuotaReached" + TARGET_STATUS_NOT_SUCCESS = "TargetStatusNotSuccess" + TARGET_QUOTA_REACHED = "TargetQuotaReached" + PROJECT_SUSPENDED = "ProjectSuspended" + PROJECT_INACTIVE = "ProjectInactive" + # We have never seen a real response for a database in this state, so this + # casing comes from Vuforia's result codes table rather than from an + # observed response. + PROJECT_HAS_NO_API_ACCESS = "ProjectHasNoApiAccess" + INACTIVE_PROJECT = "InactiveProject" + TOO_MANY_REQUESTS = "TooManyRequests" + INVALID_ACCEPT_HEADER = "InvalidAcceptHeader" + INVALID_INSTANCE_ID = "InvalidInstanceId" + BAD_REQUEST = "BadRequest" + INVALID_TARGET_TYPE = "InvalidTargetType" +@beartype +@unique class TargetStatuses(Enum): - """ - Constants representing VWS target statuses. + """Constants representing VWS target statuses. See the 'status' field in - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Retrieve-a-Target-Record + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record """ - PROCESSING = 'processing' - SUCCESS = 'success' - FAILED = 'failed' + PROCESSING = "processing" + SUCCESS = "success" + FAILED = "failed" diff --git a/src/mock_vws/_database_matchers.py b/src/mock_vws/_database_matchers.py index ee8084c17..dae0253a7 100644 --- a/src/mock_vws/_database_matchers.py +++ b/src/mock_vws/_database_matchers.py @@ -1,84 +1,27 @@ -""" -Helpers for getting databases which match keys given in requests. -""" +"""Helpers for getting databases which match keys given in requests.""" -import base64 -import hashlib -import hmac -from typing import Dict, Iterable, Optional +from collections.abc import Iterable, Mapping -from mock_vws.database import VuforiaDatabase +from beartype import beartype +from vws_auth_tools import authorization_header +from mock_vws.database import CloudDatabase, VuMarkDatabase -def _compute_hmac_base64(key: bytes, data: bytes) -> bytes: - """ - Return the Base64 encoded HMAC-SHA1 hash of the given `data` using the - provided `key`. - """ - hashed = hmac.new(key=key, msg=None, digestmod=hashlib.sha1) - hashed.update(msg=data) - return base64.b64encode(s=hashed.digest()) - - -def _authorization_header( # pylint: disable=too-many-arguments - access_key: str, - secret_key: str, - method: str, - content: bytes, - content_type: str, - date: str, - request_path: str, -) -> str: - """ - Return an `Authorization` header which can be used for a request made to - the VWS API with the given attributes. - - Args: - access_key: A VWS server or client access key. - secret_key: A VWS server or client secret key. - method: The HTTP method which will be used in the request. - content: The request body which will be used in the request. - content_type: The `Content-Type` header which will be used in the - request. - date: The current date which must exactly match the date sent in the - `Date` header. - request_path: The path to the endpoint which will be used in the - request. - - Returns: - An `Authorization` header which can be used for a request made to the - VWS API with the given attributes. - """ - hashed = hashlib.md5() - hashed.update(content) - content_md5_hex = hashed.hexdigest() - - components_to_sign = [ - method, - content_md5_hex, - content_type, - date, - request_path, - ] - string_to_sign = '\n'.join(components_to_sign) - signature = _compute_hmac_base64( - key=secret_key.encode(), - data=bytes(string_to_sign, encoding='utf-8'), - ) - auth_header = f'VWS {access_key}:{signature.decode()}' - return auth_header +AnyDatabase = CloudDatabase | VuMarkDatabase +@beartype def get_database_matching_client_keys( - request_headers: Dict[str, str], - request_body: Optional[bytes], + *, + request_headers: Mapping[str, str], + request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[VuforiaDatabase], -) -> Optional[VuforiaDatabase]: - """ - Return which, if any, of the given databases is being accessed by the given - client request. + databases: Iterable[CloudDatabase], +) -> CloudDatabase: + """Return the first of the given databases which is being accessed by + the + given client request. Args: request_headers: The headers sent with the request. @@ -89,18 +32,23 @@ def get_database_matching_client_keys( Returns: The database which is being accessed by the given client request. + + Raises: + ValueError: No database matches the given request. """ - content_type = request_headers.get('Content-Type', '').split(';')[0] - auth_header = request_headers.get('Authorization') - content = request_body or b'' - date = request_headers.get('Date', '') + request_headers_dict = dict(request_headers) + content_type = request_headers_dict.get("Content-Type", "").split(sep=";")[ + 0 + ] + auth_header = request_headers_dict.get("Authorization") + date = request_headers_dict.get("Date", "") for database in databases: - expected_authorization_header = _authorization_header( + expected_authorization_header = authorization_header( access_key=database.client_access_key, secret_key=database.client_secret_key, method=request_method, - content=content, + content=request_body, content_type=content_type, date=date, request_path=request_path, @@ -108,19 +56,21 @@ def get_database_matching_client_keys( if auth_header == expected_authorization_header: return database - return None + raise ValueError -def get_database_matching_server_keys( - request_headers: Dict[str, str], - request_body: Optional[bytes], +@beartype +def get_database_matching_server_keys[DatabaseT: AnyDatabase]( + *, + request_headers: Mapping[str, str], + request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[VuforiaDatabase], -) -> Optional[VuforiaDatabase]: - """ - Return which, if any, of the given databases is being accessed by the given - server request. + databases: Iterable[DatabaseT], +) -> DatabaseT: + """Return the first of the given databases which is being accessed by + the + given server request. Args: request_headers: The headers sent with the request. @@ -131,18 +81,22 @@ def get_database_matching_server_keys( Returns: The database being accessed by the given server request. + + Raises: + ValueError: No database matches the given request. """ - content_type = request_headers.get('Content-Type', '').split(';')[0] - auth_header = request_headers.get('Authorization') - content = request_body or b'' - date = request_headers.get('Date', '') + request_headers_dict = dict(request_headers) + content_type_header = request_headers_dict.get("Content-Type", "") + content_type = content_type_header.split(sep=";")[0] + auth_header = request_headers_dict.get("Authorization") + date = request_headers_dict.get("Date", "") for database in databases: - expected_authorization_header = _authorization_header( + expected_authorization_header = authorization_header( access_key=database.server_access_key, secret_key=database.server_secret_key, method=request_method, - content=content, + content=request_body, content_type=content_type, date=date, request_path=request_path, @@ -150,4 +104,4 @@ def get_database_matching_server_keys( if auth_header == expected_authorization_header: return database - return None + raise ValueError diff --git a/src/mock_vws/_flask_server/Dockerfile b/src/mock_vws/_flask_server/Dockerfile new file mode 100644 index 000000000..47e76ff30 --- /dev/null +++ b/src/mock_vws/_flask_server/Dockerfile @@ -0,0 +1,41 @@ +FROM ghcr.io/astral-sh/uv:0.11.7-python3.14-trixie-slim AS base +# We set this pretend version as we do not have Git in our path, and we do +# not care enough about having the version correct inside the Docker container +# to install it. +ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 +# Avoid using root user. Use an explicit UID so the container does not rely on +# the host being able to resolve the account name. +# Create /app here because the legacy (non-BuildKit) builder creates +# WORKDIR directories owned by root, not the current user. +RUN useradd --create-home --shell /bin/bash --uid 10001 myuser \ + && mkdir /app \ + && chown 10001:10001 /app +USER 10001 + +# See https://pythonspeed.com/articles/activate-virtualenv-dockerfile/ +# For why we use this method of activating the virtual environment. +ENV UV_PROJECT_ENVIRONMENT=/app/docker_venvs/.venv +ENV PATH="$UV_PROJECT_ENVIRONMENT/bin:$PATH" + +WORKDIR /app +# Install the locked dependencies before copying the source, so that +# source edits do not invalidate the dependency layer. +COPY --chown=10001:10001 pyproject.toml uv.lock /app/ +RUN uv sync --locked --no-cache --no-install-project +COPY --chown=10001:10001 . /app +RUN uv sync --locked --no-cache +EXPOSE 5000 +ENTRYPOINT ["python"] +HEALTHCHECK --interval=1s --timeout=10s --start-period=5s --retries=3 CMD ["python", "/app/src/mock_vws/_flask_server/healthcheck.py"] + +FROM base AS vws +ENV VWS_HOST=0.0.0.0 +CMD ["src/mock_vws/_flask_server/vws.py"] + +FROM base AS vwq +ENV VWQ_HOST=0.0.0.0 +CMD ["src/mock_vws/_flask_server/vwq.py"] + +FROM base AS target-manager +ENV TARGET_MANAGER_HOST=0.0.0.0 +CMD ["src/mock_vws/_flask_server/target_manager.py"] diff --git a/src/mock_vws/_flask_server/__init__.py b/src/mock_vws/_flask_server/__init__.py new file mode 100644 index 000000000..81533727f --- /dev/null +++ b/src/mock_vws/_flask_server/__init__.py @@ -0,0 +1 @@ +"""Flask server for the mock Vuforia web service.""" diff --git a/src/mock_vws/_flask_server/healthcheck.py b/src/mock_vws/_flask_server/healthcheck.py new file mode 100644 index 000000000..b84d255d6 --- /dev/null +++ b/src/mock_vws/_flask_server/healthcheck.py @@ -0,0 +1,34 @@ +"""Health check for the Flask server.""" + +import http.client +import sys +from http import HTTPStatus + +from beartype import beartype + + +@beartype +def flask_app_healthy(port: int) -> bool: + """Check if the Flask app is healthy.""" + conn = http.client.HTTPConnection(host="localhost", port=port) + try: + conn.request(method="GET", url="/some-random-endpoint") + response = conn.getresponse() + # ``OSError`` covers ``TimeoutError``, ``ConnectionRefusedError`` and + # ``socket.gaierror``. + # ``ConnectionRefusedError`` is the expected error while the container is + # starting up and nothing is yet listening on the port. + except OSError, http.client.HTTPException: + return False + finally: + conn.close() + + return response.status in { + HTTPStatus.NOT_FOUND, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + } + + +if __name__ == "__main__": # pragma: no cover + sys.exit(int(not flask_app_healthy(port=5000))) diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py new file mode 100644 index 000000000..90e973477 --- /dev/null +++ b/src/mock_vws/_flask_server/target_manager.py @@ -0,0 +1,536 @@ +"""Storage layer for the mock Vuforia Flask application.""" + +import base64 +import copy +import datetime +import json +from enum import StrEnum, auto +from http import HTTPMethod, HTTPStatus +from typing import assert_never +from zoneinfo import ZoneInfo + +from beartype import beartype +from flask import Flask, Response, request +from pydantic_settings import BaseSettings + +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.database_type import DatabaseType +from mock_vws.model_target import ModelTargetDataset +from mock_vws.request_rate_limits import RequestRateLimits +from mock_vws.states import States +from mock_vws.target import ImageTarget, VuMarkTarget +from mock_vws.target_manager import TargetManager +from mock_vws.target_raters import ( + BrisqueTargetTrackingRater, + HardcodedTargetTrackingRater, + RandomTargetTrackingRater, + TargetTrackingRater, +) + +TARGET_MANAGER_FLASK_APP = Flask(import_name=__name__, static_folder=None) + +TARGET_MANAGER = TargetManager() + + +@beartype +class _TargetRaterChoice(StrEnum): + """Target rater choices.""" + + BRISQUE = auto() + PERFECT = auto() + RANDOM = auto() + + def to_target_rater( + self: _TargetRaterChoice, + ) -> TargetTrackingRater: + """Get the target rater.""" + match self: + case _TargetRaterChoice.BRISQUE: + return BrisqueTargetTrackingRater() + case _TargetRaterChoice.PERFECT: + return HardcodedTargetTrackingRater(rating=5) + case _TargetRaterChoice.RANDOM: + return RandomTargetTrackingRater() + case _ as unreachable: + assert_never(unreachable) + + +@beartype +class TargetManagerSettings(BaseSettings): + """Settings for the Target Manager Flask app.""" + + target_manager_host: str = "" + target_rater: _TargetRaterChoice = _TargetRaterChoice.BRISQUE + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/cloud_databases/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_cloud_database(database_name: str) -> Response: + """Delete a cloud database. + + :status 200: The cloud database has been deleted. + """ + try: + (matching_database,) = { + database + for database in TARGET_MANAGER.cloud_databases + if database_name == database.database_name + } + except ValueError: + return Response(response="", status=HTTPStatus.NOT_FOUND) + + TARGET_MANAGER.remove_cloud_database(cloud_database=matching_database) + return Response(response="", status=HTTPStatus.OK) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/vumark_databases/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_vumark_database(database_name: str) -> Response: + """Delete a VuMark database. + + :status 200: The VuMark database has been deleted. + """ + try: + (matching_database,) = { + database + for database in TARGET_MANAGER.vumark_databases + if database_name == database.database_name + } + except ValueError: + return Response(response="", status=HTTPStatus.NOT_FOUND) + + TARGET_MANAGER.remove_vumark_database(vumark_database=matching_database) + return Response(response="", status=HTTPStatus.OK) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/cloud_databases", methods=[HTTPMethod.GET] +) +@beartype +def get_cloud_databases() -> Response: + """Return a list of all cloud databases.""" + databases = [ + database.to_dict() for database in TARGET_MANAGER.cloud_databases + ] + return Response( + response=json.dumps(obj=databases), + status=HTTPStatus.OK, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/vumark_databases", + methods=[HTTPMethod.GET], +) +@beartype +def get_vumark_databases() -> Response: + """Return a list of all VuMark databases.""" + databases = [ + database.to_dict() for database in TARGET_MANAGER.vumark_databases + ] + return Response( + response=json.dumps(obj=databases), + status=HTTPStatus.OK, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/cloud_databases", methods=[HTTPMethod.POST] +) +@beartype +def create_cloud_database() -> Response: + """Create a new cloud database. + + :reqheader Content-Type: application/json + :resheader Content-Type: application/json + + :reqjson string client_access_key: (Optional) The client access key for the + cloud database. + + :reqjson string client_secret_key: (Optional) The client secret key for the + cloud database. + + :reqjson string database_name: (Optional) The name of the cloud database. + + :reqjson int request_quota: (Optional) The request quota. Set this to zero + to make VWS endpoints return ``RequestQuotaReached``. + + :reqjson int target_quota: (Optional) The target quota. Once this many + targets exist, adding another returns ``TargetQuotaReached``. + + :reqjson int requests_per_second_limit: (Optional) The maximum number of + VWS requests accepted in a rolling one-second window, across all VWS + endpoints. Set this to zero to make VWS endpoints return + ``TooManyRequests``. + + :reqjson request_rate_limits: (Optional) Request rate limits for + individual groups of VWS endpoints. This is an object with the optional + keys "other", "get_target", "get_duplicates" and "list_targets", each + either null or an object with the keys "max_requests" and + "window_seconds". + + :reqjson string server_access_key: (Optional) The server access key for the + cloud database. + + :reqjson string server_secret_key: (Optional) The server secret key for the + cloud database. + + :reqjson string state_name: (Optional) The state of the cloud database. + This can be "WORKING", "PROJECT_INACTIVE", "PROJECT_SUSPENDED", or + "PROJECT_HAS_NO_API_ACCESS". This defaults to "WORKING". + + :resjson string client_access_key: The client access key for the cloud + database. + + :resjson string client_secret_key: The client secret key for the cloud + database. + + :resjson string database_name: The cloud database name. + + :resjson int request_quota: The request quota. + + :resjson int target_quota: The target quota. + + :resjson int requests_per_second_limit: The per-second request limit, or + null when rate limiting is disabled. + + :resjson request_rate_limits: The per-endpoint request rate limits, or + null when per-endpoint rate limiting is disabled. + + :resjson string server_access_key: The server access key for the cloud + database. + + :resjson string server_secret_key: The server secret key for the cloud + database. + + :resjson string state_name: The cloud database state. + + :reqjsonarr targets: The targets in the cloud database. + + :status 201: The cloud database has been successfully created. + """ + random_database = CloudDatabase() + request_json = json.loads(s=request.data) + server_access_key = request_json.get( + "server_access_key", + random_database.server_access_key, + ) + server_secret_key = request_json.get( + "server_secret_key", + random_database.server_secret_key, + ) + client_access_key = request_json.get( + "client_access_key", + random_database.client_access_key, + ) + client_secret_key = request_json.get( + "client_secret_key", + random_database.client_secret_key, + ) + database_id = request_json.get( + "database_id", + random_database.database_id, + ) + database_name = request_json.get( + "database_name", + random_database.database_name, + ) + state_name = request_json.get( + "state_name", + random_database.state.name, + ) + database_type_name = request_json.get( + "database_type_name", + random_database.database_type.name, + ) + request_quota = request_json.get( + "request_quota", + random_database.request_quota, + ) + target_quota = request_json.get( + "target_quota", + random_database.target_quota, + ) + requests_per_second_limit = request_json.get( + "requests_per_second_limit", + random_database.requests_per_second_limit, + ) + request_rate_limits_dict = request_json.get("request_rate_limits") + request_rate_limits = ( + None + if request_rate_limits_dict is None + else RequestRateLimits.from_dict(limits_dict=request_rate_limits_dict) + ) + + state = States[state_name] + database_type = DatabaseType[database_type_name] + + database = CloudDatabase( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + client_access_key=client_access_key, + client_secret_key=client_secret_key, + database_id=database_id, + database_name=database_name, + state=state, + database_type=database_type, + request_quota=request_quota, + target_quota=target_quota, + requests_per_second_limit=requests_per_second_limit, + request_rate_limits=request_rate_limits, + ) + try: + TARGET_MANAGER.add_cloud_database(cloud_database=database) + except ValueError as exc: + return Response( + response=str(object=exc), + status=HTTPStatus.CONFLICT, + ) + + return Response( + response=json.dumps(obj=database.to_dict()), + status=HTTPStatus.CREATED, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/vumark_databases", + methods=[HTTPMethod.POST], +) +@beartype +def create_vumark_database() -> Response: + """Create a new VuMark database. + + :status 201: The database has been successfully created. + """ + request_json = json.loads(s=request.data) + random_vumark_database = VuMarkDatabase() + state_name = request_json.get( + "state_name", + random_vumark_database.state.name, + ) + database = VuMarkDatabase( + server_access_key=request_json.get( + "server_access_key", + random_vumark_database.server_access_key, + ), + server_secret_key=request_json.get( + "server_secret_key", + random_vumark_database.server_secret_key, + ), + database_name=request_json.get( + "database_name", + random_vumark_database.database_name, + ), + state=States[state_name], + ) + + try: + TARGET_MANAGER.add_vumark_database(vumark_database=database) + except ValueError as exc: + return Response( + response=str(object=exc), + status=HTTPStatus.CONFLICT, + ) + + return Response( + response=json.dumps(obj=database.to_dict()), + status=HTTPStatus.CREATED, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/model_target_datasets", + methods=[HTTPMethod.GET], +) +@beartype +def get_model_target_datasets() -> Response: + """Return a list of all Model Target datasets.""" + datasets = [ + dataset.to_dict() + for dataset in TARGET_MANAGER.model_target_datasets.values() + ] + return Response( + response=json.dumps(obj=datasets), + status=HTTPStatus.OK, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/model_target_datasets", + methods=[HTTPMethod.POST], +) +@beartype +def create_model_target_dataset() -> Response: + """Create a new Model Target dataset. + + :status 201: The Model Target dataset has been successfully created. + """ + request_json = json.loads(s=request.data) + dataset = ModelTargetDataset.from_dict(dataset_dict=request_json) + TARGET_MANAGER.add_model_target_dataset(model_target_dataset=dataset) + return Response( + response=json.dumps(obj=dataset.to_dict()), + status=HTTPStatus.CREATED, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/model_target_datasets/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_model_target_dataset(dataset_uuid: str) -> Response: + """Delete a Model Target dataset. + + :status 200: The Model Target dataset has been deleted. + """ + if dataset_uuid not in TARGET_MANAGER.model_target_datasets: + return Response(response="", status=HTTPStatus.NOT_FOUND) + + TARGET_MANAGER.remove_model_target_dataset(dataset_uuid=dataset_uuid) + return Response(response="", status=HTTPStatus.OK) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/cloud_databases//targets", + methods=[HTTPMethod.POST], +) +@beartype +def create_target(database_name: str) -> Response: + """Create a new target in a given cloud database.""" + (database,) = ( + database + for database in TARGET_MANAGER.cloud_databases + if database.database_name == database_name + ) + request_json = json.loads(s=request.data) + settings = TargetManagerSettings.model_validate(obj={}) + + image_bytes = base64.b64decode(s=request_json["image_base64"]) + target_tracking_rater = settings.target_rater.to_target_rater() + target = ImageTarget( + name=request_json["name"], + width=request_json["width"], + image_value=image_bytes, + active_flag=request_json["active_flag"], + processing_time_seconds=request_json["processing_time_seconds"], + application_metadata=request_json["application_metadata"], + target_id=request_json["target_id"], + target_tracking_rater=target_tracking_rater, + ) + database.targets.add(target) + + return Response( + response=json.dumps(obj=target.to_dict()), + status=HTTPStatus.CREATED, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/vumark_databases//vumark_targets", + methods=[HTTPMethod.POST], +) +@beartype +def create_vumark_target(database_name: str) -> Response: + """Create a new VuMark target in a given database.""" + (database,) = ( + database + for database in TARGET_MANAGER.vumark_databases + if database.database_name == database_name + ) + request_json = json.loads(s=request.data) + target = VuMarkTarget.from_dict(target_dict=request_json) + database.vumark_targets.add(target) + + return Response( + response=json.dumps(obj=target.to_dict()), + status=HTTPStatus.CREATED, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/cloud_databases//targets/", + methods={HTTPMethod.DELETE}, +) +@beartype +def delete_target(database_name: str, target_id: str) -> Response: + """Delete a target.""" + (database,) = ( + database + for database in TARGET_MANAGER.cloud_databases + if database.database_name == database_name + ) + target = database.get_target(target_id=target_id) + now = datetime.datetime.now(tz=target.upload_date.tzinfo) + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + delete_date=now, + ) + database.targets.remove(target) + database.targets.add(new_target) + return Response( + response=json.dumps(obj=new_target.to_dict()), + status=HTTPStatus.OK, + ) + + +@TARGET_MANAGER_FLASK_APP.route( + rule="/cloud_databases//targets/", + methods=[HTTPMethod.PUT], +) +@beartype +def update_target(database_name: str, target_id: str) -> Response: + """Update a target.""" + (database,) = ( + database + for database in TARGET_MANAGER.cloud_databases + if database.database_name == database_name + ) + target = database.get_target(target_id=target_id) + + request_json = json.loads(s=request.data) + name = request_json.get("name", target.name) + active_flag = request_json.get("active_flag", target.active_flag) + + gmt = ZoneInfo(key="GMT") + last_modified_date = datetime.datetime.now(tz=gmt) + + width = request_json.get("width", target.width) + application_metadata = request_json.get( + "application_metadata", + target.application_metadata, + ) + image_value = target.image_value + if "image" in request_json: + image_value = base64.b64decode(s=request_json["image"]) + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + width=width, + active_flag=active_flag, + application_metadata=application_metadata, + image_value=image_value, + last_modified_date=last_modified_date, + ) + + database.targets.remove(target) + database.targets.add(new_target) + + return Response( + response=json.dumps(obj=new_target.to_dict()), + status=HTTPStatus.OK, + ) + + +if __name__ == "__main__": # pragma: no cover + SETTINGS = TargetManagerSettings.model_validate(obj={}) + TARGET_MANAGER_FLASK_APP.run(host=SETTINGS.target_manager_host) diff --git a/src/mock_vws/_flask_server/vwq.py b/src/mock_vws/_flask_server/vwq.py new file mode 100644 index 000000000..79b8252a5 --- /dev/null +++ b/src/mock_vws/_flask_server/vwq.py @@ -0,0 +1,173 @@ +"""A fake implementation of the Vuforia Web Query API using Flask. + +See +https://developer.vuforia.com/library/web-api/vuforia-query-web-api +""" + +import email.utils +import time +from enum import StrEnum, auto +from http import HTTPMethod, HTTPStatus +from typing import assert_never + +import requests +from beartype import beartype +from flask import Flask, Response, request +from pydantic_settings import BaseSettings + +from mock_vws._query_tools import ( + get_query_match_response_text, +) +from mock_vws._query_validators import run_query_validators +from mock_vws._query_validators.exceptions import ( + ValidatorError, +) +from mock_vws.database import CloudDatabase +from mock_vws.image_matchers import ( + ExactMatcher, + ImageMatcher, + StructuralSimilarityMatcher, +) + +CLOUDRECO_FLASK_APP = Flask(import_name=__name__, static_folder=None) +CLOUDRECO_FLASK_APP.config["PROPAGATE_EXCEPTIONS"] = True + + +@beartype +class _ImageMatcherChoice(StrEnum): + """Image matcher choices.""" + + EXACT = auto() + STRUCTURAL_SIMILARITY = auto() + + def to_image_matcher(self: _ImageMatcherChoice) -> ImageMatcher: + """Get the image matcher.""" + match self: + case _ImageMatcherChoice.EXACT: + return ExactMatcher() + case _ImageMatcherChoice.STRUCTURAL_SIMILARITY: + return StructuralSimilarityMatcher() + case _ as unreachable: + assert_never(unreachable) + + +@beartype +class VWQSettings(BaseSettings): + """Settings for the VWQ Flask app.""" + + vwq_host: str = "" + target_manager_base_url: str + query_image_matcher: _ImageMatcherChoice = ( + _ImageMatcherChoice.STRUCTURAL_SIMILARITY + ) + response_delay_seconds: float = 0.0 + + +@beartype +def get_all_cloud_databases() -> set[CloudDatabase]: + """Get all database objects from the target manager back-end.""" + settings = VWQSettings.model_validate(obj={}) + response = requests.get( + url=f"{settings.target_manager_base_url}/cloud_databases", + timeout=30, + ) + return { + CloudDatabase.from_dict(database_dict=database_dict) + for database_dict in response.json() + } + + +@CLOUDRECO_FLASK_APP.before_request +@beartype +def set_terminate_wsgi_input() -> None: + """We set ``wsgi.input_terminated`` to ``True`` when going through + ``requests`` in our tests, so that requests have the given ``Content- + Length`` headers and the given data in ``request.headers`` and + ``request.data``. + + We do not set this at all when running an application as standalone. + This is because when running the Flask application, if this is set, + reading ``request.data`` hangs. + + Therefore, when running the real Flask application, the behavior is not the + same as the real Vuforia. + This is documented as a difference in the documentation for this package. + """ + try: + set_terminate_wsgi_input_true = ( + CLOUDRECO_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] is True + ) + except KeyError: + set_terminate_wsgi_input_true = False + + if set_terminate_wsgi_input_true: + request.environ["wsgi.input_terminated"] = True + + +@CLOUDRECO_FLASK_APP.after_request +@beartype +def add_response_delay(response: Response) -> Response: + """Add a delay to each response.""" + settings = VWQSettings.model_validate(obj={}) + time.sleep(settings.response_delay_seconds) + return response + + +@CLOUDRECO_FLASK_APP.errorhandler(code_or_exception=ValidatorError) +@beartype +def handle_exceptions(exc: ValidatorError) -> Response: + """Return the error response associated with the given exception.""" + response = Response( + status=exc.status_code.value, + response=exc.response_text, + headers=exc.headers, + ) + + response.headers.clear() + response.headers.extend(exc.headers) + return response + + +@CLOUDRECO_FLASK_APP.route(rule="/v1/query", methods=[HTTPMethod.POST]) +@beartype +def query() -> Response: + """Perform an image recognition query.""" + settings = VWQSettings.model_validate(obj={}) + query_match_checker = settings.query_image_matcher.to_image_matcher() + + databases = get_all_cloud_databases() + request_body = request.stream.read() + run_query_validators( + request_headers=dict(request.headers), + request_body=request_body, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + + response_text = get_query_match_response_text( + request_headers=dict(request.headers), + request_body=request_body, + request_method=request.method, + request_path=request.path, + databases=databases, + query_match_checker=query_match_checker, + ) + + headers = { + "Content-Type": "application/json", + "Date": date, + "Connection": "keep-alive", + "Server": "nginx", + } + return Response( + status=HTTPStatus.OK, + response=response_text, + headers=headers, + ) + + +if __name__ == "__main__": # pragma: no cover + SETTINGS = VWQSettings.model_validate(obj={}) + CLOUDRECO_FLASK_APP.run(host=SETTINGS.vwq_host) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py new file mode 100644 index 000000000..db7da0c61 --- /dev/null +++ b/src/mock_vws/_flask_server/vws.py @@ -0,0 +1,1135 @@ +"""A fake implementation of the Vuforia Web Services API. + +See +https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api +""" + +import base64 +import email.utils +import json +import logging +import time +import uuid +from enum import StrEnum, auto +from http import HTTPMethod, HTTPStatus +from typing import assert_never + +import requests +from beartype import beartype +from flask import Flask, Response, request +from pydantic_settings import BaseSettings +from werkzeug.exceptions import MethodNotAllowed, NotFound + +from mock_vws._constants import ( + VUMARK_PDF, + VUMARK_PNG, + VUMARK_SVG, + ResultCodes, + TargetStatuses, +) +from mock_vws._database_matchers import get_database_matching_server_keys +from mock_vws._mock_common import RequestData, json_dump, sorted_targets +from mock_vws._model_target_web_api import ( + create_model_target_dataset, + delete_model_target_dataset, + download_model_target_dataset, + get_model_target_dataset_status, +) +from mock_vws._model_target_web_api import ( + oauth2_token as model_target_oauth2_token, +) +from mock_vws._reco_counts_web_api import create_reco_counts_report +from mock_vws._reco_counts_web_api import ( + download_reco_counts_report as download_report, +) +from mock_vws._services_validators import run_services_validators +from mock_vws._services_validators.exceptions import ( + FailError, + InvalidAcceptHeaderError, + InvalidTargetTypeError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + ValidatorError, +) +from mock_vws._services_validators.request_rate_validators import ( + RequestRateLimiter, +) +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.image_matchers import ( + ExactMatcher, + ImageMatcher, + StructuralSimilarityMatcher, +) +from mock_vws.model_target import ModelTargetDataset, ModelTargetDatasetType +from mock_vws.reco_counts import RecoCountsReport +from mock_vws.target import ImageTarget +from mock_vws.target_raters import ( + HardcodedTargetTrackingRater, +) + +VWS_FLASK_APP = Flask(import_name=__name__, static_folder=None) +VWS_FLASK_APP.config["PROPAGATE_EXCEPTIONS"] = True + +# In the Docker deployment the target manager service owns all database and +# target state, and each VWS app instance is otherwise stateless. +# Request rate limit history is deliberately an exception: it is tracked per +# VWS app instance, so it is lost when the app restarts. +_REQUEST_RATE_LIMITER = RequestRateLimiter(time_function=time.monotonic) + + +_LOGGER = logging.getLogger(name=__name__) + + +@beartype +class _ImageMatcherChoice(StrEnum): + """Image matcher choices.""" + + EXACT = auto() + STRUCTURAL_SIMILARITY = auto() + + def to_image_matcher(self: _ImageMatcherChoice) -> ImageMatcher: + """Get the image matcher.""" + match self: + case _ImageMatcherChoice.EXACT: + return ExactMatcher() + case _ImageMatcherChoice.STRUCTURAL_SIMILARITY: + return StructuralSimilarityMatcher() + case _ as unreachable: + assert_never(unreachable) + + +@beartype +class VWSSettings(BaseSettings): + """Settings for the VWS Flask app.""" + + target_manager_base_url: str + processing_time_seconds: float = 2.0 + vws_host: str = "" + # The base URL which clients use to reach this application. + # Generated reco counts reports are served from this URL. + vws_base_url: str = "https://vws.vuforia.com" + duplicates_image_matcher: _ImageMatcherChoice = ( + _ImageMatcherChoice.STRUCTURAL_SIMILARITY + ) + response_delay_seconds: float = 0.0 + + +@beartype +def get_all_cloud_databases() -> set[CloudDatabase]: + """Get all database objects from the task manager back-end.""" + settings = VWSSettings.model_validate(obj={}) + timeout_seconds = 30 + response = requests.get( + url=f"{settings.target_manager_base_url}/cloud_databases", + timeout=timeout_seconds, + ) + return { + CloudDatabase.from_dict(database_dict=database_dict) + for database_dict in response.json() + } + + +@beartype +def get_all_vumark_databases() -> set[VuMarkDatabase]: + """Get all VuMark database objects from the task manager back-end.""" + settings = VWSSettings.model_validate(obj={}) + timeout_seconds = 30 + response = requests.get( + url=f"{settings.target_manager_base_url}/vumark_databases", + timeout=timeout_seconds, + ) + return { + VuMarkDatabase.from_dict(database_dict=database_dict) + for database_dict in response.json() + } + + +@beartype +def _flask_request_data() -> RequestData: + """Return the current Flask request as shared request data.""" + return RequestData( + method=request.method, + path=request.path, + headers=dict(request.headers), + body=request.get_data(parse_form_data=False), + ) + + +@beartype +class _HTTPModelTargetDatasetStore: + """Model Target dataset storage backed by the target manager + service. + """ + + def __init__(self, *, base_url: str) -> None: + """ + Args: + base_url: The base URL of the target manager service. + """ + self._datasets_url = f"{base_url}/model_target_datasets" + + @property + def model_target_datasets(self) -> dict[str, ModelTargetDataset]: + """All Model Target datasets, keyed by UUID.""" + timeout_seconds = 30 + response = requests.get( + url=self._datasets_url, + timeout=timeout_seconds, + ) + datasets = ( + ModelTargetDataset.from_dict(dataset_dict=dataset_dict) + for dataset_dict in response.json() + ) + return {dataset.uuid_: dataset for dataset in datasets} + + def add_model_target_dataset( + self, + model_target_dataset: ModelTargetDataset, + ) -> None: + """Add a Model Target dataset.""" + timeout_seconds = 30 + requests.post( + url=self._datasets_url, + json=model_target_dataset.to_dict(), + timeout=timeout_seconds, + ) + + def remove_model_target_dataset(self, dataset_uuid: str) -> None: + """Remove a Model Target dataset.""" + timeout_seconds = 30 + requests.delete( + url=f"{self._datasets_url}/{dataset_uuid}", + timeout=timeout_seconds, + ) + + +@beartype +def _model_target_dataset_store() -> _HTTPModelTargetDatasetStore: + """Return the dataset store backing the Model Target routes.""" + settings = VWSSettings.model_validate(obj={}) + return _HTTPModelTargetDatasetStore( + base_url=settings.target_manager_base_url, + ) + + +@beartype +class _InMemoryRecoCountsReportStore: + """Reco counts report storage for this app instance. + + Generated reports are served by this app, standing in for the presigned + cloud storage URLs which real Vuforia returns, so reports are stored in + this app rather than in the target manager service. + """ + + def __init__(self) -> None: + """Create a store with no reports.""" + self._reports: dict[str, RecoCountsReport] = {} + + @property + def reco_counts_reports(self) -> dict[str, RecoCountsReport]: + """All reco counts reports, keyed by report identifier.""" + return dict(self._reports) + + def add_reco_counts_report( + self, + # The parameter name matches the ``RecoCountsReportStore`` protocol, + # and also happens to match the name of a route function in this + # module. + reco_counts_report: RecoCountsReport, # pylint: disable=redefined-outer-name + ) -> None: + """Add a reco counts report.""" + self._reports[reco_counts_report.uuid_] = reco_counts_report + + +_RECO_COUNTS_REPORT_STORE = _InMemoryRecoCountsReportStore() + + +@beartype +def _to_flask_response( + api_response: tuple[int, dict[str, str], str | bytes], +) -> Response: + """Convert a shared API response to a Flask response.""" + status_code, headers, body = api_response + return Response(response=body, status=status_code, headers=headers) + + +@VWS_FLASK_APP.before_request +@beartype +def set_terminate_wsgi_input() -> None: + """We set ``wsgi.input_terminated`` to ``True`` when going through + ``requests`` in our tests, so that requests have the given ``Content- + Length`` headers and the given data in ``request.headers`` and + ``request.data``. + + We do not set this at all when running an application as standalone. + This is because when running the Flask application, if this is set, + reading ``request.data`` hangs. + + Therefore, when running the real Flask application, the behavior is not the + same as the real Vuforia. + This is documented as a difference in the documentation for this package. + """ + try: + set_terminate_wsgi_input_true = ( + VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] is True + ) + except KeyError: + set_terminate_wsgi_input_true = False + + if set_terminate_wsgi_input_true: + request.environ["wsgi.input_terminated"] = True + + +@VWS_FLASK_APP.before_request +@beartype +def validate_request() -> None: + """Run validators on the request. + + The VuMark endpoint does its own validation because it needs to + authenticate against both cloud and VuMark databases. + + Reco counts report downloads stand in for presigned URLs, which are not + authorized with VWS credentials. + + Flask runs ``before_request`` handlers before it raises a routing error, + so requests which match no route reach this function. + Those requests are left to Flask, which raises the routing error, and + ``handle_unrouted_request`` turns that into a response. + """ + if request.url_rule is None: + return + if request.endpoint == "generate_vumark_instance": + return + if ( + request.path == "/oauth2/token" + or request.path.startswith("/modeltargets/") + or request.path.startswith("/reports/recoCounts/") + ): + return + run_services_validators( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=get_all_cloud_databases(), + request_rate_limiter=_REQUEST_RATE_LIMITER, + ) + + +@VWS_FLASK_APP.after_request +@beartype +def add_response_delay(response: Response) -> Response: + """Add a delay to each response.""" + settings = VWSSettings.model_validate(obj={}) + time.sleep(settings.response_delay_seconds) + return response + + +@VWS_FLASK_APP.errorhandler(code_or_exception=ValidatorError) +@beartype +def handle_exceptions(exc: ValidatorError) -> Response: + """Return the error response associated with the given exception.""" + response = Response( + status=exc.status_code.value, + response=exc.response_text, + headers=exc.headers, + ) + + response.headers.clear() + response.headers.extend(exc.headers) + return response + + +@VWS_FLASK_APP.errorhandler(code_or_exception=HTTPStatus.NOT_FOUND) +@VWS_FLASK_APP.errorhandler(code_or_exception=HTTPStatus.METHOD_NOT_ALLOWED) +@beartype +def handle_unrouted_request(exc: NotFound | MethodNotAllowed) -> Response: + """Return a 404 response with no body for a request which no route + serves. + + Real Vuforia returns a 404 response for a request to a path which it does + not serve, and for a request to a served path with a method which that + path does not serve. + """ + del exc + response = Response(status=HTTPStatus.NOT_FOUND, response=b"") + del response.headers["Content-Type"] + return response + + +@VWS_FLASK_APP.route(rule="/oauth2/token", methods=[HTTPMethod.POST]) +@beartype +def oauth2_token() -> Response: + """Obtain an OAuth2 token for the Model Target Web API.""" + return _to_flask_response( + api_response=model_target_oauth2_token( + request=_flask_request_data(), + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets", + methods=[HTTPMethod.POST], +) +@beartype +def create_standard_model_target_dataset() -> Response: + """Create a standard Model Target dataset.""" + settings = VWSSettings.model_validate(obj={}) + return _to_flask_response( + api_response=create_model_target_dataset( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + processing_time_seconds=settings.processing_time_seconds, + dataset_type=ModelTargetDatasetType.STANDARD, + generation_failure=None, + generation_warning=None, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets", + methods=[HTTPMethod.POST], +) +@beartype +def create_advanced_model_target_dataset() -> Response: + """Create an advanced Model Target dataset.""" + settings = VWSSettings.model_validate(obj={}) + return _to_flask_response( + api_response=create_model_target_dataset( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + processing_time_seconds=settings.processing_time_seconds, + dataset_type=ModelTargetDatasetType.ADVANCED, + generation_failure=None, + generation_warning=None, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets//status", + methods=[HTTPMethod.GET], +) +@beartype +def get_standard_model_target_dataset_status( + dataset_uuid: str, +) -> Response: + """Return a standard Model Target dataset creation status.""" + return _to_flask_response( + api_response=get_model_target_dataset_status( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets//status", + methods=[HTTPMethod.GET], +) +@beartype +def get_advanced_model_target_dataset_status( + dataset_uuid: str, +) -> Response: + """Return an advanced Model Target dataset creation status.""" + return _to_flask_response( + api_response=get_model_target_dataset_status( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets//dataset", + methods=[HTTPMethod.GET], +) +@beartype +def download_standard_model_target_dataset( + dataset_uuid: str, +) -> Response: + """Download a standard Model Target dataset.""" + return _to_flask_response( + api_response=download_model_target_dataset( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets//dataset", + methods=[HTTPMethod.GET], +) +@beartype +def download_advanced_model_target_dataset( + dataset_uuid: str, +) -> Response: + """Download an advanced Model Target dataset.""" + return _to_flask_response( + api_response=download_model_target_dataset( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_standard_model_target_dataset(dataset_uuid: str) -> Response: + """Delete a standard Model Target dataset.""" + return _to_flask_response( + api_response=delete_model_target_dataset( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_advanced_model_target_dataset(dataset_uuid: str) -> Response: + """Delete an advanced Model Target dataset.""" + return _to_flask_response( + api_response=delete_model_target_dataset( + request=_flask_request_data(), + dataset_store=_model_target_dataset_store(), + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/imagetargets/databases//reports/recoCounts", + methods=[HTTPMethod.POST], +) +@beartype +def reco_counts_report(database_id: str) -> Response: + """Request a reco counts report for a database. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api + """ + # The database ID in the path is validated against the request's server + # keys before the request reaches this route. + del database_id + settings = VWSSettings.model_validate(obj={}) + return _to_flask_response( + api_response=create_reco_counts_report( + request_body=request.data, + report_store=_RECO_COUNTS_REPORT_STORE, + generation_time_seconds=settings.processing_time_seconds, + base_url=settings.vws_base_url.rstrip("/"), + ), + ) + + +@VWS_FLASK_APP.route( + rule="/reports/recoCounts/", + methods=[HTTPMethod.GET], +) +@beartype +def download_reco_counts_report(report_id: str) -> Response: + """Download a generated reco counts report. + + This stands in for the presigned URL which real Vuforia returns, so it + does not require any authorization. + """ + return _to_flask_response( + api_response=download_report( + report_store=_RECO_COUNTS_REPORT_STORE, + report_id=report_id, + ), + ) + + +@VWS_FLASK_APP.route(rule="/targets", methods=[HTTPMethod.POST]) +@beartype +def add_target() -> Response: + """Add a target. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add + """ + settings = VWSSettings.model_validate(obj={}) + databases = get_all_cloud_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + + # We do not use ``request.get_json(force=True)`` because this only works + # when the content type is given as ``application/json``. + request_json = json.loads(s=request.data) + name = request_json["name"] + active_flag = request_json.get("active_flag") + if active_flag is None: + active_flag = True + + # This rater is not used. + target_tracking_rater = HardcodedTargetTrackingRater(rating=1) + + new_target = ImageTarget( + name=name, + width=request_json["width"], + image_value=base64.b64decode(s=request_json["image"]), + active_flag=active_flag, + processing_time_seconds=settings.processing_time_seconds, + application_metadata=request_json.get("application_metadata"), + target_tracking_rater=target_tracking_rater, + ) + + databases_url = f"{settings.target_manager_base_url}/cloud_databases" + timeout_seconds = 30 + requests.post( + url=f"{databases_url}/{database.database_name}/targets", + json=new_target.to_dict(), + timeout=timeout_seconds, + ) + + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_CREATED.value, + "target_id": new_target.target_id, + } + + return Response( + status=HTTPStatus.CREATED, + response=json_dump(body=body), + headers=headers, + ) + + +@VWS_FLASK_APP.route( + rule="/targets/", methods=[HTTPMethod.GET] +) +@beartype +def get_target(target_id: str) -> Response: + """Get details of a target. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record + """ + databases = get_all_cloud_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + + (target,) = ( + target for target in database.targets if target.target_id == target_id + ) + + width = target.width + tracking_rating = target.tracking_rating + reco_rating = target.reco_rating + target_record = { + "target_id": target.target_id, + "active_flag": target.active_flag, + "name": target.name, + "width": width, + "tracking_rating": tracking_rating, + "reco_rating": reco_rating, + } + + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + body = { + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "target_record": target_record, + "status": target.status, + } + return Response( + status=HTTPStatus.OK, + response=json_dump(body=body), + headers=headers, + ) + + +@VWS_FLASK_APP.route( + rule="/targets/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_target(target_id: str) -> Response: + """Delete a target. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete + """ + settings = VWSSettings.model_validate(obj={}) + databases = get_all_cloud_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + + (target,) = ( + target for target in database.targets if target.target_id == target_id + ) + + if target.status == TargetStatuses.PROCESSING.value: + raise TargetStatusProcessingError + + databases_url = f"{settings.target_manager_base_url}/cloud_databases" + requests.delete( + url=f"{databases_url}/{database.database_name}/targets/{target_id}", + timeout=30, + ) + + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + } + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return Response( + status=HTTPStatus.OK, + response=json_dump(body=body), + headers=headers, + ) + + +@VWS_FLASK_APP.route( + rule="/targets//instances", + methods=[HTTPMethod.POST], +) +@beartype +def generate_vumark_instance(target_id: str) -> Response: + """Generate a VuMark instance. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#generate-instance + """ + cloud_databases = get_all_cloud_databases() + vumark_databases = get_all_vumark_databases() + all_databases: list[CloudDatabase | VuMarkDatabase] = [ + *cloud_databases, + *vumark_databases, + ] + run_services_validators( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=all_databases, + request_rate_limiter=_REQUEST_RATE_LIMITER, + ) + + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=all_databases, + ) + if not isinstance(database, VuMarkDatabase): + raise InvalidTargetTypeError + + target = database.get_vumark_target(target_id=target_id) + if target.status != TargetStatuses.SUCCESS.value: + raise TargetStatusNotSuccessError + + accept = request.headers.get(key="Accept", default="") + valid_accept_types: dict[str, bytes] = { + "image/png": VUMARK_PNG, + "image/svg+xml": VUMARK_SVG, + "application/pdf": VUMARK_PDF, + } + if accept not in valid_accept_types: + raise InvalidAcceptHeaderError + + response_body = valid_accept_types[accept] + content_type = accept + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": content_type, + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return Response( + status=HTTPStatus.OK, + response=response_body, + headers=headers, + ) + + +@VWS_FLASK_APP.route(rule="/summary", methods=[HTTPMethod.GET]) +@beartype +def database_summary() -> Response: + """Get a database summary report. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report + """ + databases = get_all_cloud_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + + body = { + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "name": database.database_name, + "active_images": len(database.active_targets), + "inactive_images": len(database.inactive_targets), + "failed_images": len(database.failed_targets), + "target_quota": database.target_quota, + "total_recos": database.total_recos, + "current_month_recos": database.current_month_recos, + "previous_month_recos": database.previous_month_recos, + "processing_images": len(database.processing_targets), + "reco_threshold": database.reco_threshold, + "request_quota": database.request_quota, + # We have ``self.request_count`` but Vuforia always shows 0. + # This was not always the case. + "request_usage": 0, + } + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return Response( + status=HTTPStatus.OK, + response=json_dump(body=body), + headers=headers, + ) + + +@VWS_FLASK_APP.route( + rule="/summary/", + methods=[HTTPMethod.GET], +) +@beartype +def target_summary(target_id: str) -> Response: + """Get a summary report for a target. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#retrieve-report + """ + databases = get_all_cloud_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + + (target,) = ( + target for target in database.targets if target.target_id == target_id + ) + tracking_rating = target.tracking_rating + total_recos = target.total_recos + current_month_recos = target.current_month_recos + previous_month_recos = target.previous_month_recos + body = { + "status": target.status, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "database_name": database.database_name, + "target_name": target.name, + "upload_date": target.upload_date.strftime(format="%Y-%m-%d"), + "active_flag": target.active_flag, + "tracking_rating": tracking_rating, + "total_recos": total_recos, + "current_month_recos": current_month_recos, + "previous_month_recos": previous_month_recos, + } + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return Response( + status=HTTPStatus.OK, + response=json_dump(body=body), + headers=headers, + ) + + +@VWS_FLASK_APP.route( + rule="/duplicates/", + methods=[HTTPMethod.GET], +) +@beartype +def get_duplicates(target_id: str) -> Response: + """Get targets which may be considered duplicates of a given target. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check + """ + databases = get_all_cloud_databases() + settings = VWSSettings.model_validate(obj={}) + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + image_match_checker = settings.duplicates_image_matcher.to_image_matcher() + + (target,) = ( + target for target in database.targets if target.target_id == target_id + ) + other_targets = sorted_targets(targets=database.targets - {target}) + + similar_targets = [ + other.target_id + for other in other_targets + if image_match_checker( + first_image_content=target.image_value, + second_image_content=other.image_value, + ) + and TargetStatuses.FAILED.value not in {target.status, other.status} + and TargetStatuses.PROCESSING.value != other.status + and other.active_flag + ] + + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "similar_targets": similar_targets, + } + + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return Response( + status=HTTPStatus.OK, + response=json_dump(body=body), + headers=headers, + ) + + +@VWS_FLASK_APP.route(rule="/targets", methods=[HTTPMethod.GET]) +@beartype +def target_list() -> Response: + """Get a list of all targets. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list + """ + databases = get_all_cloud_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + results = [ + target.target_id + for target in sorted_targets(targets=database.not_deleted_targets) + ] + + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "results": results, + } + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return Response( + status=HTTPStatus.OK, + response=json_dump(body=body), + headers=headers, + ) + + +@VWS_FLASK_APP.route( + rule="/targets/", methods=[HTTPMethod.PUT] +) +@beartype +def update_target(target_id: str) -> Response: + """Update a target. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update + """ + settings = VWSSettings.model_validate(obj={}) + # We do not use ``request.get_json(force=True)`` because this only works + # when the content type is given as ``application/json``. + request_json = json.loads(s=request.data) + databases = get_all_cloud_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + + (target,) = ( + target for target in database.targets if target.target_id == target_id + ) + + if target.status != TargetStatuses.SUCCESS.value: + raise TargetStatusNotSuccessError + + update_values: dict[str, str | int | float | bool | None] = {} + if "width" in request_json: + update_values["width"] = request_json["width"] + + if "active_flag" in request_json: + active_flag = request_json["active_flag"] + if active_flag is None: + _LOGGER.warning( + msg=( + 'The value of "active_flag" was None. ' + "This is not allowed. " + ), + ) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + update_values["active_flag"] = active_flag + + if "application_metadata" in request_json: + application_metadata = request_json["application_metadata"] + if application_metadata is None: + _LOGGER.warning( + msg=( + 'The value of "application_metadata" was None. ' + "This is not allowed." + ), + ) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + update_values["application_metadata"] = application_metadata + + if "name" in request_json: + name = request_json["name"] + update_values["name"] = name + + if "image" in request_json: + image = request_json["image"] + update_values["image"] = image + + put_url = ( + f"{settings.target_manager_base_url}/cloud_databases/" + f"{database.database_name}/targets/{target_id}" + ) + requests.put(url=put_url, json=update_values, timeout=30) + + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + body = { + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + } + return Response( + status=HTTPStatus.OK, + response=json_dump(body=body), + headers=headers, + ) + + +if __name__ == "__main__": # pragma: no cover + SETTINGS = VWSSettings.model_validate(obj={}) + VWS_FLASK_APP.run(host=SETTINGS.vws_host) diff --git a/src/mock_vws/_image_opening.py b/src/mock_vws/_image_opening.py new file mode 100644 index 000000000..a2449b45c --- /dev/null +++ b/src/mock_vws/_image_opening.py @@ -0,0 +1,47 @@ +"""Open images without Pillow's decompression bomb protection.""" + +import contextlib +import threading +from collections.abc import Generator +from typing import IO + +from PIL import Image + +# ``Image.MAX_IMAGE_PIXELS`` is a module level setting, so it is changed for +# as short a time as possible, and only by one thread at a time. +_MAX_IMAGE_PIXELS_LOCK = threading.Lock() + + +# This is not decorated with ``@beartype`` because beartype does not accept +# an ``io.BytesIO`` for an ``IO[bytes]`` parameter, and that is what most +# callers give. +@contextlib.contextmanager +def open_image(*, fp: IO[bytes]) -> Generator[Image.Image]: + """Open an image however many pixels it has. + + Pillow raises :class:`PIL.Image.DecompressionBombError` when opening an + image with more than twice ``Image.MAX_IMAGE_PIXELS`` pixels, and a small + file can decode to many more pixels than that. + Real Vuforia returns a response for such an image rather than failing to + respond, so the mock must be able to open one. + + Pillow checks the pixel count when the image is opened, not when it is + decoded, so ``Image.MAX_IMAGE_PIXELS`` is restored before the image is + used. + + Args: + fp: A file object with the content of the image. + + Yields: + The opened image. + """ + with _MAX_IMAGE_PIXELS_LOCK: + original_max_image_pixels = Image.MAX_IMAGE_PIXELS + Image.MAX_IMAGE_PIXELS = None + try: + image = Image.open(fp=fp) + finally: + Image.MAX_IMAGE_PIXELS = original_max_image_pixels + + with image: + yield image diff --git a/src/mock_vws/_mock_common.py b/src/mock_vws/_mock_common.py index 1da413cc1..02ca9bec1 100644 --- a/src/mock_vws/_mock_common.py +++ b/src/mock_vws/_mock_common.py @@ -1,22 +1,69 @@ -""" -Common utilities for creating mock routes. -""" +"""Common utilities for creating mock routes.""" -import email.utils import json +from collections.abc import Iterable, Mapping from dataclasses import dataclass -from http import HTTPStatus -from typing import Any, Callable, Dict, FrozenSet, Tuple +from typing import Any + +from beartype import beartype + +from mock_vws.target import ImageTarget + +# A database ID as it appears in the path of a reco counts report request. +DATABASE_ID_PATTERN = "[A-Za-z0-9_-]+" +# The path of the endpoint which requests a reco counts report. +RECO_COUNTS_REPORT_PATH_PATTERN = ( + f"/imagetargets/databases/{DATABASE_ID_PATTERN}/reports/recoCounts" +) +# The path which stands in for a reco counts report presigned URL. +RECO_COUNTS_DOWNLOAD_PATH_PATTERN = "/reports/recoCounts/[A-Za-z0-9]+" + + +@beartype +class MissingSchemeError(Exception): + """Raised when a URL is missing a schema.""" + + def __init__(self, url: str) -> None: + """ + Args: + url: The URL which is missing a scheme. + """ + super().__init__() + self.url = url + + def __str__(self) -> str: + """ + Give a string representation of this error with a + suggestion. + """ + return ( + f'Invalid URL "{self.url}": No scheme supplied. ' + f'Perhaps you meant "https://{self.url}".' + ) + + +@beartype +@dataclass(frozen=True, kw_only=True) +class RequestData: + """A library-agnostic representation of an HTTP request. -import wrapt -from requests_mock.request import _RequestObjectProxy -from requests_mock.response import _Context + Args: + method: The HTTP method of the request. + path: The path of the request. + headers: The headers sent with the request. + body: The body of the request. + """ + method: str + path: str + headers: Mapping[str, str] + body: bytes -@dataclass(frozen=True) + +@beartype +@dataclass(frozen=True, kw_only=True) class Route: - """ - A representation of a VWS route. + """A representation of a VWS route. Args: route_name: The name of the method. @@ -27,69 +74,33 @@ class Route: route_name: str path_pattern: str - http_methods: FrozenSet[str] + http_methods: Iterable[str] -def json_dump(body: Dict[str, Any]) -> str: - """ - Returns: - JSON dump of data in the same way that Vuforia dumps data. - """ - return json.dumps(obj=body, separators=(',', ':')) +@beartype +def sorted_targets(*, targets: Iterable[ImageTarget]) -> list[ImageTarget]: + """Put targets into a deterministic order. - -@wrapt.decorator -def set_content_length_header( - wrapped: Callable[..., str], - instance: Any, # pylint: disable=unused-argument - args: Tuple[_RequestObjectProxy, _Context], - kwargs: Dict, -) -> str: - """ - Set the `Content-Length` header. + Targets are held in a ``set``, so iterating over them gives an order which + varies between runs. Endpoints which return lists of targets use this so + that repeated runs agree with each other. Args: - wrapped: An endpoint function for `requests_mock`. - instance: The class that the endpoint function is in. - args: The arguments given to the endpoint function. - kwargs: The keyword arguments given to the endpoint function. + targets: The targets to order. Returns: - The result of calling the endpoint. + The given targets, ordered by upload date and then by target ID. """ - _, context = args + return sorted( + targets, + key=lambda target: (target.upload_date, target.target_id), + ) - result = wrapped(*args, **kwargs) - context.headers['Content-Length'] = str(len(result)) - return result - -@wrapt.decorator -def set_date_header( - wrapped: Callable[..., str], - instance: Any, # pylint: disable=unused-argument - args: Tuple[_RequestObjectProxy, _Context], - kwargs: Dict, -) -> str: +@beartype +def json_dump(*, body: dict[str, Any]) -> str: """ - Set the `Date` header. - - Args: - wrapped: An endpoint function for `requests_mock`. - instance: The class that the endpoint function is in. - args: The arguments given to the endpoint function. - kwargs: The keyword arguments given to the endpoint function. - Returns: - The result of calling the endpoint. + JSON dump of data in the same way that Vuforia dumps data. """ - _, context = args - date = email.utils.formatdate(None, localtime=False, usegmt=True) - - result = wrapped(*args, **kwargs) - if ( - context.headers['Connection'] != 'Close' - and context.status_code != HTTPStatus.GATEWAY_TIMEOUT - ): - context.headers['Date'] = date - return result + return json.dumps(obj=body, separators=(",", ":")) diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py new file mode 100644 index 000000000..b097a6162 --- /dev/null +++ b/src/mock_vws/_model_target_web_api.py @@ -0,0 +1,1119 @@ +"""A fake implementation of the Model Target Web API.""" + +import base64 +import io +import json +import uuid +import zipfile +from http import HTTPStatus +from typing import Any, Protocol, runtime_checkable +from urllib.parse import parse_qs + +from beartype import beartype + +from mock_vws._mock_common import RequestData, json_dump +from mock_vws._services_validators.exceptions import ( + ContentLengthHeaderNotIntError, +) +from mock_vws.model_target import ( + ModelTargetDataset, + ModelTargetDatasetType, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) + +_ResponseType = tuple[int, dict[str, str], str | bytes] + + +@runtime_checkable +class ModelTargetDatasetStore(Protocol): + """Storage for Model Target datasets.""" + + @property + def model_target_datasets(self) -> dict[str, ModelTargetDataset]: + """All Model Target datasets, keyed by UUID.""" + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + def add_model_target_dataset( + self, + model_target_dataset: ModelTargetDataset, + ) -> None: + """Add a Model Target dataset.""" + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + def remove_model_target_dataset(self, dataset_uuid: str) -> None: + """Remove a Model Target dataset.""" + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + +_MAX_ADVANCED_MODEL_COUNT = 20 +_JWT_DOT_COUNT = 2 +_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) +_MOCK_MODEL_TARGET_CLIENT_ID = "client-id" +_MOCK_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105 +# A stable mock value standing in for the user-id segment that real +# Vuforia embeds in some Model Target error targets such as +# ``userId:7635391``. The numeric portion is per-account in real Vuforia; +# the mock uses a fixed placeholder. +_MOCK_USER_TARGET = "userId:mock" +# The enumerated model field values documented by the Model Target Web API +# OpenAPI specification. +_MODEL_ENUM_FIELD_VALUES: dict[str, frozenset[str]] = { + "automaticColoring": frozenset({"always", "auto", "never"}), + "cadDataFormat": frozenset( + { + "DAE", + "FBX", + "GLB", + "IGES", + "OBJ", + "PVZ", + "STL", + "VRML", + "ZIP", + }, + ), + "motionHint": frozenset({"adaptive", "dynamic", "static"}), + "optimizeTrackingFor": frozenset( + {"ar_controller", "default", "low_feature_objects"}, + ), + "simplify": frozenset({"always", "auto", "never"}), + "trackingMode": frozenset({"car", "default", "scan"}), +} +# The training status which the download route reports for a dataset which +# is not ready to download, keyed by the status which the status route +# reports. +# +# Real Vuforia reports ``not-started`` for a dataset which was created just +# before the download request, so the mock uses that name for the whole +# processing window. The name for a dataset whose generation failed has not +# been observed. +_TRAINING_STATUSES: dict[str, str] = { + "processing": "not-started", + "failed": "failed", +} +# ``realisticAppearance`` is documented as an enumerated model field for +# advanced datasets only. +_ADVANCED_MODEL_ENUM_FIELD_VALUES: dict[str, frozenset[str]] = { + **_MODEL_ENUM_FIELD_VALUES, + "realisticAppearance": frozenset({"auto", "false", "true"}), +} + + +@beartype +def _json_response( + *, + status_code: HTTPStatus, + body: dict[str, Any], +) -> _ResponseType: + """Return a JSON response.""" + body_json = json_dump(body=body) + return ( + status_code, + { + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + }, + body_json, + ) + + +@beartype +def _error_response( + *, + status_code: HTTPStatus, + code: str, + message: str, + target: str | None, + details: list[dict[str, str]] | None, +) -> _ResponseType: + """Return an error response shaped like the Model Target Web API.""" + error: dict[str, Any] = {"code": code, "message": message} + if target is not None: + error["target"] = target + if details is not None: + error["details"] = details + return _json_response(status_code=status_code, body={"error": error}) + + +@beartype +def _validation_error_response( + *, + details: list[dict[str, str]], +) -> _ResponseType: + """Return a Vuforia-style validation error. + + Real Vuforia tags each validation error with a per-request UUID that + appears in both ``message`` and ``target``. The mock generates a fresh + UUID so the shape matches. + """ + request_uuid = uuid.uuid4().hex + return _error_response( + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message=f"Validation error for request {request_uuid}", + target=request_uuid, + details=details, + ) + + +@beartype +def _oauth2_error_response( + *, + status_code: HTTPStatus, + body: dict[str, str], +) -> _ResponseType: + """Return an OAuth2 error response.""" + return _json_response(status_code=status_code, body=body) + + +@beartype +def _get_header(request: RequestData, name: str) -> str | None: + """Return a request header, case-insensitively.""" + lower_name = name.casefold() + for key, value in request.headers.items(): + if key.casefold() == lower_name: + return value + return None + + +@beartype +def _content_length_error(request: RequestData) -> _ResponseType | None: + """Return an error response if ``Content-Length`` is not an integer. + + The load balancer in front of real Vuforia rejects a request with a + ``Content-Length`` header which is not an integer before the request + reaches any API, so the Model Target Web API gives the same response + as the VWS API does. + + A ``Content-Length`` header which is too large is not handled here. + Real Vuforia waits for the body it was promised and then times out, + which is too slow to verify in a test. + """ + given_content_length = _get_header(request=request, name="Content-Length") + if given_content_length is None: + return None + + try: + int(given_content_length) + except ValueError: + error = ContentLengthHeaderNotIntError() + return (error.status_code, dict(error.headers), error.response_text) + + return None + + +@beartype +def _basic_auth_credentials(auth_header: str | None) -> tuple[str, str] | None: + """Return HTTP Basic credentials from an authorization header.""" + if auth_header is None or not auth_header.startswith("Basic "): + return None + + encoded_credentials = auth_header.removeprefix("Basic ").strip() + try: + decoded_credentials = base64.b64decode( + s=encoded_credentials, + validate=True, + ).decode(encoding="utf-8") + except ValueError: + return None + + client_id, separator, client_secret = decoded_credentials.partition(":") + if not separator: + return None + + return client_id, client_secret + + +@beartype +def _jwt_header_error(*, bearer_token: str) -> str | None: + """Return the Vuforia error for an invalid JSON Web Token header.""" + encoded_header = bearer_token.partition(".")[0] + try: + padding = "=" * (-len(encoded_header) % 4) + decoded_header = base64.b64decode( + s=encoded_header + padding, + altchars=b"-_", + validate=True, + ) + header = json.loads(s=decoded_header) + except ValueError: + header = None + + if not isinstance(header, dict): + return "Invalid unsecured/JWS/JWE header: Invalid JSON object" + if "alg" not in header: + return 'Missing "alg" in header JSON object' + if header["alg"] == "none": + return "Unsecured (plain) JWTs are rejected, extend class to handle" + return None + + +@beartype +def _jwt_payload_error(*, bearer_token: str) -> str | None: + """Return the Vuforia error for an invalid JSON Web Token payload.""" + encoded_payload = bearer_token.split(sep=".")[1] + try: + padding = "=" * (-len(encoded_payload) % 4) + decoded_payload = base64.b64decode( + s=encoded_payload + padding, + altchars=b"-_", + validate=True, + ) + payload = json.loads(s=decoded_payload) + except ValueError: + payload = None + + if not isinstance(payload, dict): + return "Payload of JWS object is not a valid JSON object" + return None + + +@beartype +def _jwt_signature_error(*, bearer_token: str) -> str | None: + """Return the Vuforia error for an invalid JSON Web Token + signature. + """ + encoded_signature = bearer_token.rpartition(".")[2] + if not encoded_signature: + return "The signature must not be empty" + + try: + padding = "=" * (-len(encoded_signature) % 4) + base64.b64decode( + s=encoded_signature + padding, + altchars=b"-_", + validate=True, + ) + except ValueError: + return "Signed JWT rejected: Invalid signature" + + return None + + +@beartype +def _require_bearer_token(request: RequestData) -> _ResponseType | None: + """Return an error response if the request has no bearer token.""" + auth_header = _get_header(request=request, name="Authorization") + if auth_header is None or not auth_header.startswith("Bearer "): + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + details=None, + ) + bearer_token = auth_header.removeprefix("Bearer ").strip() + if not bearer_token: + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + details=None, + ) + if bearer_token.count(".") != _JWT_DOT_COUNT: + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="Invalid JWT serialization: Missing dot delimiter(s)", + target="jwt", + details=None, + ) + jwt_error = _jwt_header_error(bearer_token=bearer_token) + if jwt_error is None: + jwt_error = _jwt_payload_error(bearer_token=bearer_token) + if jwt_error is None: + jwt_error = _jwt_signature_error(bearer_token=bearer_token) + if jwt_error is not None: + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message=jwt_error, + target="jwt", + details=None, + ) + return None + + +@beartype +def _fake_jwt(*, token_source: bytes) -> str: + """Return a deterministic bearer token for the mock.""" + + def encode_part(value: dict[str, Any]) -> str: + """Return a base64url-encoded token part.""" + raw_part = json.dumps( + obj=value, + sort_keys=True, + separators=(",", ":"), + ).encode(encoding="utf-8") + return ( + base64.urlsafe_b64encode(s=raw_part) + .decode( + encoding="ascii", + ) + .rstrip("=") + ) + + header = encode_part(value={"alg": "mock", "typ": "JWT"}) + payload = encode_part( + value={ + "aud": "vuforia-model-target", + "src": base64.urlsafe_b64encode(s=token_source) + .decode( + encoding="ascii", + ) + .rstrip("="), + }, + ) + return f"{header}.{payload}.mock-signature" + + +@beartype +def oauth2_token(request: RequestData) -> _ResponseType: + """Return a fake OAuth2 access token.""" + content_length_error = _content_length_error(request=request) + if content_length_error is not None: + return content_length_error + + auth_header = _get_header(request=request, name="Authorization") + # A form body which is not valid UTF-8 is decoded leniently rather than + # raising, so that a body which cannot be decoded is treated as one which + # does not name a grant type. + form = parse_qs( + qs=request.body.decode(encoding="utf-8", errors="replace"), + ) + grant_type = form.get("grant_type", ["client_credentials"])[0] + if grant_type != "client_credentials": + return _oauth2_error_response( + status_code=HTTPStatus.BAD_REQUEST, + body={"error": "unsupported_grant_type"}, + ) + + basic_credentials = _basic_auth_credentials(auth_header=auth_header) + if basic_credentials is None: + return _oauth2_error_response( + status_code=HTTPStatus.UNAUTHORIZED, + body={ + "error": "invalid_request", + "error_description": ( + "Missing or invalid authorization header" + ), + }, + ) + + if basic_credentials != ( + _MOCK_MODEL_TARGET_CLIENT_ID, + _MOCK_MODEL_TARGET_CLIENT_SECRET, + ): + return _oauth2_error_response( + status_code=HTTPStatus.UNAUTHORIZED, + body={"error": "invalid_client"}, + ) + + token_source = request.body or (auth_header or "").encode() + return _json_response( + status_code=HTTPStatus.OK, + body={ + "access_token": _fake_jwt(token_source=token_source), + "token_type": "bearer", + "expires_in": 3600, + }, + ) + + +@beartype +def _is_json_object(*, value: object) -> bool: + """Return whether a decoded JSON value is an object.""" + return isinstance(value, dict) + + +@beartype +def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType: + """Load a Model Target dataset creation request body.""" + content_type = _get_header(request=request, name="Content-Type") or "" + if "application/json" not in content_type: + return _error_response( + status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + code="ERROR", + message="Expecting text/json or application/json body", + target=None, + details=None, + ) + try: + request_json: dict[str, Any] = json.loads( + s=request.body.decode(encoding="utf-8"), + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + return _error_response( + status_code=HTTPStatus.BAD_REQUEST, + code="ERROR", + message=f"Invalid Json: {exc}", + target=None, + details=None, + ) + if not _is_json_object(value=request_json): + # The required top-level fields are read from the request body, so a + # body which is valid JSON but not a JSON object is reported as + # having every required field missing. + return _validation_error_response( + details=_top_level_details(request_json={}), + ) + return request_json + + +@beartype +def _cad_data_source_details(*, models: list[Any]) -> list[dict[str, str]]: + """Return validation details for each model's CAD data source. + + One and only one of ``cadDataUrl`` and ``cadDataBlob`` may be given per + model. + """ + return [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({index}): one and only one of cadDataUrl and " + "cadDataBlob is required" + ), + } + for index, model in enumerate(iterable=models) + if ("cadDataUrl" in model) == ("cadDataBlob" in model) + ] + + +@beartype +def _model_field_details( + *, + models: list[Any], + dataset_type: ModelTargetDatasetType, +) -> list[dict[str, str]]: + """Return validation details for the fields of each model.""" + enum_field_values = ( + _ADVANCED_MODEL_ENUM_FIELD_VALUES + if dataset_type == ModelTargetDatasetType.ADVANCED + else _MODEL_ENUM_FIELD_VALUES + ) + missing_details = [ + { + "code": "VALIDATION_ERROR", + "message": f"/models({index})/name: element is required", + } + for index, model in enumerate(iterable=models) + if "name" not in model + ] + cad_data_source_details = _cad_data_source_details(models=models) + if missing_details or cad_data_source_details: + return missing_details + cad_data_source_details + + string_fields = sorted( + { + "cadDataBlob", + "cadDataUrl", + "name", + "stateBasedConfigurationJsonString", + *enum_field_values, + }, + ) + string_details = [ + { + "code": "VALIDATION_ERROR", + "message": f"/models({index})/{field}: error.expected.jsstring", + } + for index, model in enumerate(iterable=models) + for field in string_fields + if field in model and not isinstance(model[field], str) + ] + if string_details: + return string_details + + enum_details = [ + { + "code": "VALIDATION_ERROR", + "message": f"/models({index})/{field}: error.expected.validenum", + } + for index, model in enumerate(iterable=models) + for field, allowed_values in sorted(enum_field_values.items()) + if field in model and model[field] not in allowed_values + ] + views_details = [ + { + "code": "VALIDATION_ERROR", + "message": f"/models({index})/views: error.expected.jsarray", + } + for index, model in enumerate(iterable=models) + if "views" in model and not isinstance(model["views"], list) + ] + return enum_details + views_details + + +@beartype +def _view_details(*, models: list[Any]) -> list[dict[str, str]]: + """Return validation details for the guide views of each model.""" + views = [ + (model_index, view_index, view) + for model_index, model in enumerate(iterable=models) + for view_index, view in enumerate(iterable=model.get("views", [])) + ] + + object_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index}): " + "error.expected.jsobject" + ), + } + for model_index, view_index, view in views + if not isinstance(view, dict) + ] + if object_details: + return object_details + + missing_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/{field}: " + "element is required" + ), + } + for model_index, view_index, view in views + for field in ("guideViewPosition", "name") + if field not in view + ] + if missing_details: + return missing_details + + name_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/name: " + "error.expected.jsstring" + ), + } + for model_index, view_index, view in views + if not isinstance(view["name"], str) + ] + position_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})" + "/guideViewPosition: error.expected.jsobject" + ), + } + for model_index, view_index, view in views + if not isinstance(view["guideViewPosition"], dict) + ] + return name_details + position_details + + +@beartype +def _is_json_number(*, value: object) -> bool: + """Return whether a decoded JSON value is a number. + + A JSON boolean decodes to a Python ``bool`` value, which is also an + ``int`` value, so ``bool`` values are excluded. + """ + return isinstance(value, int | float) and not isinstance(value, bool) + + +@beartype +def _guide_view_position_details( + *, + models: list[Any], +) -> list[dict[str, str]]: + """Return validation details for the guide view positions.""" + positions = [ + (model_index, view_index, view["guideViewPosition"]) + for model_index, model in enumerate(iterable=models) + for view_index, view in enumerate(iterable=model.get("views", [])) + ] + + missing_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})" + f"/guideViewPosition/{field}: element is required" + ), + } + for model_index, view_index, position in positions + for field in ("rotation", "translation") + if field not in position + ] + if missing_details: + return missing_details + + array_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})" + f"/guideViewPosition/{field}: error.expected.jsarray" + ), + } + for model_index, view_index, position in positions + for field in ("rotation", "translation") + if not isinstance(position[field], list) + ] + if array_details: + return array_details + + return [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})" + f"/guideViewPosition/{field}({element_index}): " + "error.expected.jsnumber" + ), + } + for model_index, view_index, position in positions + for field in ("rotation", "translation") + for element_index, element in enumerate(iterable=position[field]) + if not _is_json_number(value=element) + ] + + +@beartype +def _configuration_states( + *, + model_index: int, + configuration_string: str, +) -> tuple[frozenset[str] | None, dict[str, str] | None]: + """Load the state names from a State-Based Model Target config.""" + try: + configuration: Any = json.loads(s=configuration_string) + except json.JSONDecodeError: + return None, { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/stateBasedConfigurationJsonString: " + "error.expected.validjson" + ), + } + if not _is_json_object(value=configuration): + return None, { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/stateBasedConfigurationJsonString/" + "states: error.expected.jsobject" + ), + } + configuration_states_value: object = configuration.get("states") + if not _is_json_object(value=configuration_states_value): + return None, { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/stateBasedConfigurationJsonString/" + "states: error.expected.jsobject" + ), + } + configuration_states: dict[str, Any] = configuration["states"] + state_names = frozenset(configuration_states) + return state_names, None + + +@beartype +def _state_based_details(*, models: list[Any]) -> list[dict[str, str]]: + """Return validation details for State-Based Model Targets.""" + state_fields = [ + (model_index, view_index, view["states"]) + for model_index, model in enumerate(iterable=models) + for view_index, view in enumerate(iterable=model.get("views", [])) + if "states" in view + ] + array_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/states: " + "error.expected.jsarray" + ), + } + for model_index, view_index, states in state_fields + if not isinstance(states, list) + ] + if array_details: + return array_details + + element_details = [ + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/states" + f"({state_index}): error.expected.jsstring" + ), + } + for model_index, view_index, states in state_fields + for state_index, state in enumerate(iterable=states) + if not isinstance(state, str) + ] + if element_details: + return element_details + + details: list[dict[str, str]] = [] + configured_states: dict[int, frozenset[str]] = {} + for model_index, model in enumerate(iterable=models): + configuration_string = model.get("stateBasedConfigurationJsonString") + if not isinstance(configuration_string, str): + continue + state_names, detail = _configuration_states( + model_index=model_index, + configuration_string=configuration_string, + ) + if detail is not None: + details.append(detail) + if state_names is not None: + configured_states[model_index] = state_names + + if details: + return details + + for model_index, view_index, states in state_fields: + if model_index not in configured_states: + details.append( + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/" + "stateBasedConfigurationJsonString: element is " + "required when view states are given" + ), + }, + ) + continue + details.extend( + { + "code": "VALIDATION_ERROR", + "message": ( + f"/models({model_index})/views({view_index})/states" + f"({state_index}): error.expected.validenum" + ), + } + for state_index, state in enumerate(iterable=states) + if state not in configured_states[model_index] + ) + + return details + + +@beartype +def _model_count_details( + *, + models: list[Any], + dataset_type: ModelTargetDatasetType, +) -> list[dict[str, str]]: + """Return validation details for the number of models.""" + model_count = len(models) + + if dataset_type == ModelTargetDatasetType.STANDARD and model_count != 1: + return [ + { + "code": "VALIDATION_ERROR", + "message": "exactly one model should be provided", + }, + ] + + if ( + dataset_type == ModelTargetDatasetType.ADVANCED + and not 1 <= model_count <= _MAX_ADVANCED_MODEL_COUNT + ): + return [ + { + "code": "VALIDATION_ERROR", + "message": ( + "models must contain between 1 and " + f"{_MAX_ADVANCED_MODEL_COUNT} entries" + ), + }, + ] + + return [] + + +@beartype +def _top_level_details( + *, + request_json: dict[str, Any], +) -> list[dict[str, str]]: + """Return validation details for the top-level dataset fields.""" + missing_details = [ + { + "code": "VALIDATION_ERROR", + "message": f"/{field}: element is required", + } + for field in ("models", "name", "targetSdk") + if field not in request_json + ] + if missing_details: + return missing_details + + type_details = [ + { + "code": "VALIDATION_ERROR", + "message": f"/{field}: error.expected.jsstring", + } + for field in ("name", "targetSdk") + if not isinstance(request_json[field], str) + ] + + models_value = request_json["models"] + if not isinstance(models_value, list): + type_details.append( + { + "code": "VALIDATION_ERROR", + "message": "/models: error.expected.jsarray", + }, + ) + return type_details + + models: list[Any] = [*models_value] + type_details.extend( + { + "code": "VALIDATION_ERROR", + "message": f"/models({index}): error.expected.jsobject", + } + for index, model in enumerate(iterable=models) + if not isinstance(model, dict) + ) + return type_details + + +@beartype +def _validate_dataset_request( + *, + request_json: dict[str, Any], + dataset_type: ModelTargetDatasetType, +) -> _ResponseType | None: + """Validate the dataset request enough for useful mock feedback.""" + details = _top_level_details(request_json=request_json) + if not details: + models: list[Any] = [*request_json["models"]] + details = ( + _model_field_details(models=models, dataset_type=dataset_type) + or _view_details(models=models) + or _guide_view_position_details(models=models) + or _state_based_details(models=models) + or _model_count_details( + models=models, + dataset_type=dataset_type, + ) + ) + + if details: + return _validation_error_response(details=details) + + return None + + +@beartype +def create_model_target_dataset( + *, + request: RequestData, + dataset_store: ModelTargetDatasetStore, + processing_time_seconds: float, + dataset_type: ModelTargetDatasetType, + generation_failure: ModelTargetGenerationFailure | None, + generation_warning: ModelTargetGenerationWarning | None, +) -> _ResponseType: + """Create a standard or advanced Model Target dataset.""" + content_length_error = _content_length_error(request=request) + if content_length_error is not None: + return content_length_error + + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + + request_json_or_error = _load_request_json(request=request) + if not isinstance(request_json_or_error, dict): + return request_json_or_error + + validation_error = _validate_dataset_request( + request_json=request_json_or_error, + dataset_type=dataset_type, + ) + if validation_error is not None: + return validation_error + + dataset = ModelTargetDataset( + request_body=request_json_or_error, + dataset_type=dataset_type, + processing_time_seconds=processing_time_seconds, + generation_failure=generation_failure, + generation_warning=generation_warning, + ) + dataset_store.add_model_target_dataset(model_target_dataset=dataset) + return _json_response( + status_code=HTTPStatus.CREATED, + body={"uuid": dataset.uuid_}, + ) + + +@beartype +def _unknown_dataset_response(*, dataset_uuid: str) -> _ResponseType: + """Return the error for a dataset which is not visible to a route.""" + return _error_response( + status_code=HTTPStatus.NOT_FOUND, + code="NOT_FOUND", + message=( + f"Could not find a model-view database with uuid {dataset_uuid}" + ), + target=_MOCK_USER_TARGET, + details=None, + ) + + +@beartype +def _find_dataset( + *, + dataset_store: ModelTargetDatasetStore, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, +) -> ModelTargetDataset | None: + """Return a dataset which belongs to a route's dataset type. + + Standard and advanced datasets are separate resources in real Vuforia, so + a dataset is invisible to the routes of the other dataset type. + """ + dataset = dataset_store.model_target_datasets.get(dataset_uuid) + if dataset is None or dataset.dataset_type != dataset_type: + return None + return dataset + + +@beartype +def get_model_target_dataset_status( + *, + request: RequestData, + dataset_store: ModelTargetDatasetStore, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, +) -> _ResponseType: + """Return the status of a Model Target dataset.""" + content_length_error = _content_length_error(request=request) + if content_length_error is not None: + return content_length_error + + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + dataset = _find_dataset( + dataset_store=dataset_store, + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + if dataset is None: + return _unknown_dataset_response(dataset_uuid=dataset_uuid) + return _json_response( + status_code=HTTPStatus.OK, + body=dataset.status_body(), + ) + + +@beartype +def _dataset_zip_bytes(dataset: ModelTargetDataset) -> bytes: + """Return a small valid zip file for a generated dataset.""" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(file=zip_buffer, mode="w") as zip_file: + dataset_file = zipfile.ZipInfo( + filename="dataset.json", + date_time=_ZIP_EPOCH, + ) + zip_file.writestr( + zinfo_or_arcname=dataset_file, + data=json.dumps( + obj={ + "uuid": dataset.uuid_, + "type": dataset.dataset_type.value, + "request": dataset.request_body, + }, + separators=(",", ":"), + sort_keys=True, + ), + ) + return zip_buffer.getvalue() + + +@beartype +def download_model_target_dataset( + *, + request: RequestData, + dataset_store: ModelTargetDatasetStore, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, +) -> _ResponseType: + """Download a generated Model Target dataset.""" + content_length_error = _content_length_error(request=request) + if content_length_error is not None: + return content_length_error + + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + dataset = _find_dataset( + dataset_store=dataset_store, + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + if dataset is None: + return _unknown_dataset_response(dataset_uuid=dataset_uuid) + if dataset.status != "done": + training_status = _TRAINING_STATUSES[dataset.status] + return _error_response( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + code="UNSUPPORTED_STATE", + message=( + f"Training status for dataset {dataset_uuid} is " + f"{training_status} != done" + ), + target=dataset_uuid, + details=None, + ) + + body = _dataset_zip_bytes(dataset=dataset) + return ( + HTTPStatus.OK, + { + "Content-Length": str(object=len(body)), + "Content-Type": "application/zip", + }, + body, + ) + + +@beartype +def delete_model_target_dataset( + *, + request: RequestData, + dataset_store: ModelTargetDatasetStore, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, +) -> _ResponseType: + """Delete a Model Target dataset.""" + content_length_error = _content_length_error(request=request) + if content_length_error is not None: + return content_length_error + + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + dataset = _find_dataset( + dataset_store=dataset_store, + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + if dataset is None: + return _unknown_dataset_response(dataset_uuid=dataset_uuid) + dataset_store.remove_model_target_dataset(dataset_uuid=dataset_uuid) + return HTTPStatus.OK, {"Content-Length": "0"}, "" diff --git a/src/mock_vws/_query_tools.py b/src/mock_vws/_query_tools.py index 102570639..cc8034421 100644 --- a/src/mock_vws/_query_tools.py +++ b/src/mock_vws/_query_tools.py @@ -1,56 +1,32 @@ -""" -Tools for making Vuforia queries. -""" +"""Tools for making Vuforia queries.""" import base64 -import cgi -import datetime import io import uuid -from typing import Any, Dict, List, Set, Union +from collections.abc import Iterable, Mapping +from email.message import EmailMessage +from typing import Any -from backports.zoneinfo import ZoneInfo +from beartype import beartype +from werkzeug.formparser import MultiPartParser from mock_vws._base64_decoding import decode_base64 from mock_vws._constants import ResultCodes, TargetStatuses from mock_vws._database_matchers import get_database_matching_client_keys -from mock_vws._mock_common import json_dump -from mock_vws.database import VuforiaDatabase - - -class MatchingTargetsWithProcessingStatus(Exception): - """ - There is at least one matching target which has the status 'processing'. - """ - - -class ActiveMatchingTargetsDeleteProcessing(Exception): - """ - There is at least one active target which matches and was recently deleted. - """ - - -def _images_match(image: io.BytesIO, another_image: io.BytesIO) -> bool: - """ - Given two images, return whether they are matching. - - In the real Vuforia, this matching is fuzzy. - For now, we check exact byte matching. - - See https://github.com/VWS-Python/vws-python-mock/issues/3 for changing - that. - """ - return bool(image.getvalue() == another_image.getvalue()) +from mock_vws._mock_common import json_dump, sorted_targets +from mock_vws.database import CloudDatabase +from mock_vws.image_matchers import ImageMatcher +@beartype def get_query_match_response_text( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, request_method: str, request_path: str, - databases: Set[VuforiaDatabase], - query_processes_deletion_seconds: Union[int, float], - query_recognizes_deletion_seconds: Union[int, float], + databases: Iterable[CloudDatabase], + query_match_checker: ImageMatcher, ) -> str: """ Args: @@ -59,50 +35,31 @@ def get_query_match_response_text( request_body: The body of the request. request_method: The HTTP method of the request. databases: All Vuforia databases. - query_recognizes_deletion_seconds: The number of seconds after a target - has been deleted that the query endpoint will still recognize the - target for. - query_processes_deletion_seconds: The number of seconds after a target - deletion is recognized that the query endpoint will return a 500 - response on a match. + query_match_checker: A callable which takes two image values and + returns whether they match. Returns: The response text for a query endpoint request. - - Raises: - MatchingTargetsWithProcessingStatus: There is at least one matching - target which has the status 'processing'. - ActiveMatchingTargetsDeleteProcessing: There is at least one active - target which matches and was recently deleted. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + email_message = EmailMessage() + email_message["Content-Type"] = request_headers["Content-Type"] + boundary = email_message.get_boundary(failobj="") + + parser = MultiPartParser() + fields, files = parser.parse( + stream=io.BytesIO(initial_bytes=request_body), + boundary=boundary.encode(encoding="utf-8"), + content_length=len(request_body), ) - [max_num_results] = parsed.get('max_num_results', ['1']) - - [include_target_data] = parsed.get('include_target_data', ['top']) - include_target_data = include_target_data.lower() - - [image_bytes] = parsed['image'] - assert isinstance(image_bytes, bytes) - image = io.BytesIO(image_bytes) - gmt = ZoneInfo('GMT') - now = datetime.datetime.now(tz=gmt) - - processing_timedelta = datetime.timedelta( - seconds=query_processes_deletion_seconds, - ) + max_num_results = fields.get(key="max_num_results", default="1") + include_target_data = fields.get( + key="include_target_data", + default="top", + ).lower() - recognition_timedelta = datetime.timedelta( - seconds=query_recognizes_deletion_seconds, - ) + image_part = files["image"] + image_value = image_part.stream.read() database = get_database_matching_client_keys( request_headers=request_headers, @@ -112,92 +69,68 @@ def get_query_match_response_text( databases=databases, ) - assert isinstance(database, VuforiaDatabase) - matching_targets = [ target - for target in database.targets - if _images_match(image=target.image, another_image=image) + for target in sorted_targets(targets=database.targets) + if query_match_checker( + first_image_content=target.image_value, + second_image_content=image_value, + ) ] not_deleted_matches = [ target for target in matching_targets if target.active_flag + # In the real Vuforia, targets which have just + # been deleted may still get recognized. + # We document this difference in ``differences-to-vws.rst``. and not target.delete_date and target.status == TargetStatuses.SUCCESS.value ] - deletion_not_recognized_matches = [ - target - for target in matching_targets - if target.active_flag - and target.delete_date - and (now - target.delete_date) < recognition_timedelta - ] - - matching_targets_with_processing_status = [ - target - for target in matching_targets - if target.status == TargetStatuses.PROCESSING.value + all_quality_matches = not_deleted_matches + minimum_rating = 0 + matches = [ + match + for match in all_quality_matches + if match.tracking_rating > minimum_rating ] - active_matching_targets_delete_processing = [ - target - for target in matching_targets - if target.active_flag - and target.delete_date - and (now - target.delete_date) - < (recognition_timedelta + processing_timedelta) - and target not in deletion_not_recognized_matches - ] - - if matching_targets_with_processing_status: - raise MatchingTargetsWithProcessingStatus - - if active_matching_targets_delete_processing: - raise ActiveMatchingTargetsDeleteProcessing - - matches = not_deleted_matches + deletion_not_recognized_matches - - results: List[Dict[str, Any]] = [] + results: list[dict[str, Any]] = [] for target in matches: target_timestamp = target.last_modified_date.timestamp() if target.application_metadata is None: application_metadata = None else: application_metadata = base64.b64encode( - decode_base64(encoded_data=target.application_metadata), - ).decode('ascii') + s=decode_base64(encoded_data=target.application_metadata), + ).decode(encoding="ascii") target_data = { - 'target_timestamp': int(target_timestamp), - 'name': target.name, - 'application_metadata': application_metadata, + "target_timestamp": int(target_timestamp), + "name": target.name, + "application_metadata": application_metadata, } - if include_target_data == 'all': - result = { - 'target_id': target.target_id, - 'target_data': target_data, - } - elif include_target_data == 'top' and not results: + if include_target_data == "all" or ( + include_target_data == "top" and not results + ): result = { - 'target_id': target.target_id, - 'target_data': target_data, + "target_id": target.target_id, + "target_data": target_data, } else: result = { - 'target_id': target.target_id, + "target_id": target.target_id, } results.append(result) results = results[: int(max_num_results)] body = { - 'result_code': ResultCodes.SUCCESS.value, - 'results': results, - 'query_id': uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "results": results, + "query_id": uuid.uuid4().hex, } - value = json_dump(body) - return value + return json_dump(body=body) diff --git a/src/mock_vws/_query_validators/__init__.py b/src/mock_vws/_query_validators/__init__.py index 91375f923..454767494 100644 --- a/src/mock_vws/_query_validators/__init__.py +++ b/src/mock_vws/_query_validators/__init__.py @@ -1,10 +1,10 @@ -""" -Input validators to use in the mock query API. -""" +"""Input validators to use in the mock query API.""" -from typing import Dict, Set +from collections.abc import Iterable, Mapping -from mock_vws.database import VuforiaDatabase +from beartype import beartype + +from mock_vws.database import CloudDatabase from .accept_header_validators import validate_accept_header from .auth_validators import ( @@ -38,15 +38,16 @@ from .project_state_validators import validate_project_state +@beartype def run_query_validators( + *, request_path: str, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Set[VuforiaDatabase], + databases: Iterable[CloudDatabase], ) -> None: - """ - Run all validators. + """Run all validators. Args: request_path: The path of the request. @@ -86,6 +87,9 @@ def run_query_validators( databases=databases, ) validate_accept_header(request_headers=request_headers) + validate_date_header_given(request_headers=request_headers) + validate_date_format(request_headers=request_headers) + validate_date_in_range(request_headers=request_headers) validate_content_type_header( request_headers=request_headers, request_body=request_body, @@ -122,6 +126,3 @@ def run_query_validators( request_headers=request_headers, request_body=request_body, ) - validate_date_header_given(request_headers=request_headers) - validate_date_format(request_headers=request_headers) - validate_date_in_range(request_headers=request_headers) diff --git a/src/mock_vws/_query_validators/accept_header_validators.py b/src/mock_vws/_query_validators/accept_header_validators.py index 3650a4a66..e2387f913 100644 --- a/src/mock_vws/_query_validators/accept_header_validators.py +++ b/src/mock_vws/_query_validators/accept_header_validators.py @@ -1,25 +1,31 @@ -""" -Validators for the ``Accept`` header. -""" +"""Validators for the ``Accept`` header.""" -from typing import Dict +import logging +from collections.abc import Mapping -from mock_vws._query_validators.exceptions import InvalidAcceptHeader +from beartype import beartype +from mock_vws._query_validators.exceptions import InvalidAcceptHeaderError -def validate_accept_header(request_headers: Dict[str, str]) -> None: - """ - Validate the accept header. +_LOGGER = logging.getLogger(name=__name__) + + +@beartype +def validate_accept_header(*, request_headers: Mapping[str, str]) -> None: + """Validate the accept header. Args: request_headers: The headers sent with the request. Raises: - InvalidAcceptHeader: The Accept header is given and is not + InvalidAcceptHeaderError: The Accept header is given and is not 'application/json' or '*/*'. """ - accept = request_headers.get('Accept') - if accept in ('application/json', '*/*', None): + accept = request_headers.get("Accept") + if accept in {"application/json", "*/*", None}: return - raise InvalidAcceptHeader + _LOGGER.warning( + msg="The Accept header is not 'application/json' or '*/*'.", + ) + raise InvalidAcceptHeaderError diff --git a/src/mock_vws/_query_validators/auth_validators.py b/src/mock_vws/_query_validators/auth_validators.py index 77cfe1e74..ddd4310ea 100644 --- a/src/mock_vws/_query_validators/auth_validators.py +++ b/src/mock_vws/_query_validators/auth_validators.py @@ -1,111 +1,122 @@ -""" -Authorization validators to use in the mock query API. -""" +"""Authorization validators to use in the mock query API.""" -from typing import Dict, Set +import logging +from collections.abc import Iterable, Mapping + +from beartype import beartype from mock_vws._database_matchers import get_database_matching_client_keys from mock_vws._query_validators.exceptions import ( - AuthenticationFailure, - AuthHeaderMissing, - MalformedAuthHeader, - QueryOutOfBounds, + AuthenticationFailureError, + AuthHeaderMissingError, + MalformedAuthHeaderError, ) -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase +_LOGGER = logging.getLogger(name=__name__) -def validate_auth_header_exists(request_headers: Dict[str, str]) -> None: - """ - Validate that there is an authorization header given to the query endpoint. + +@beartype +def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: + """Validate that there is an authorization header given to the query + endpoint. Args: request_headers: The headers sent with the request. Raises: - AuthHeaderMissing: There is no "Authorization" header. + AuthHeaderMissingError: There is no "Authorization" header. """ - - if 'Authorization' in request_headers: + if "Authorization" in request_headers: return - raise AuthHeaderMissing + _LOGGER.warning(msg="There is no authorization header.") + raise AuthHeaderMissingError +@beartype def validate_auth_header_number_of_parts( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], ) -> None: - """ - Validate the authorization header includes text either side of a space. + """Validate the authorization header includes text either side of a + space. Args: request_headers: The headers sent with the request. Raises: - MalformedAuthHeader: The "Authorization" header is not as expected. + MalformedAuthHeaderError: The "Authorization" header is not as + expected. """ - - header = request_headers['Authorization'] - parts = header.split(' ') - if len(parts) == 2 and parts[1]: + header = request_headers["Authorization"] + parts = header.split(sep=" ") + expected_number_of_parts = 2 + if len(parts) == expected_number_of_parts and parts[1]: return - raise MalformedAuthHeader + _LOGGER.warning(msg="The authorization header is malformed.") + raise MalformedAuthHeaderError +@beartype def validate_client_key_exists( - request_headers: Dict[str, str], - databases: Set[VuforiaDatabase], + *, + request_headers: Mapping[str, str], + databases: Iterable[CloudDatabase], ) -> None: - """ - Validate the authorization header includes a client key for a database. + """Validate the authorization header includes a client key for a + database. Args: request_headers: The headers sent with the request. databases: All Vuforia databases. Raises: - AuthenticationFailure: The client key is unknown. + AuthenticationFailureError: The client key is unknown. """ - - header = request_headers['Authorization'] - first_part, _ = header.split(':') - _, access_key = first_part.split(' ') + header = request_headers["Authorization"] + first_part, _ = header.split(sep=":") + _, access_key = first_part.split(sep=" ") for database in databases: if access_key == database.client_access_key: return - raise AuthenticationFailure + _LOGGER.warning(msg="The client key is unknown.") + raise AuthenticationFailureError +@beartype def validate_auth_header_has_signature( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], ) -> None: - """ - Validate the authorization header includes a signature. + """Validate the authorization header includes a signature. Args: request_headers: The headers sent with the request. Raises: - QueryOutOfBounds: The "Authorization" header has no signature. + MalformedAuthHeaderError: The "Authorization" header has no signature. """ - - header = request_headers['Authorization'] - if header.count(':') == 1 and header.split(':')[1]: + header = request_headers["Authorization"] + if header.count(":") == 1 and header.split(sep=":")[1]: return - raise QueryOutOfBounds + _LOGGER.warning(msg="The authorization header has no signature.") + raise MalformedAuthHeaderError +@beartype def validate_authorization( + *, request_path: str, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Set[VuforiaDatabase], + databases: Iterable[CloudDatabase], ) -> None: - """ - Validate the authorization header given to the query endpoint. + """Validate the authorization header given to the query endpoint. Args: request_path: The path of the request. @@ -115,18 +126,19 @@ def validate_authorization( databases: All Vuforia databases. Raises: - AuthenticationFailure: The "Authorization" header is not as expected. + AuthenticationFailureError: The "Authorization" header is not as + expected. """ - - database = get_database_matching_client_keys( - request_headers=request_headers, - request_body=request_body, - request_method=request_method, - request_path=request_path, - databases=databases, - ) - - if database is not None: - return - - raise AuthenticationFailure + try: + get_database_matching_client_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + except ValueError as exc: + _LOGGER.warning( + msg="The authorization header does not match any databases.", + ) + raise AuthenticationFailureError from exc diff --git a/src/mock_vws/_query_validators/content_length_validators.py b/src/mock_vws/_query_validators/content_length_validators.py index f6ceb0092..4cb799fb5 100644 --- a/src/mock_vws/_query_validators/content_length_validators.py +++ b/src/mock_vws/_query_validators/content_length_validators.py @@ -1,78 +1,90 @@ -""" -Content-Length header validators to use in the mock. -""" +"""Content-Length header validators to use in the mock.""" -from typing import Dict +import logging +from collections.abc import Mapping + +from beartype import beartype from mock_vws._query_validators.exceptions import ( - AuthenticationFailureGoodFormatting, - ContentLengthHeaderNotInt, - ContentLengthHeaderTooLarge, + AuthenticationFailureGoodFormattingError, + ContentLengthHeaderNotIntError, + ContentLengthHeaderTooLargeError, ) +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_content_length_header_is_int( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], ) -> None: - """ - Validate the ``Content-Length`` header is an integer. + """Validate the ``Content-Length`` header is an integer. Args: request_headers: The headers sent with the request. Raises: - ContentLengthHeaderNotInt: ``Content-Length`` header is not an integer. + ContentLengthHeaderNotIntError: ``Content-Length`` header is not an + integer. """ - given_content_length = request_headers['Content-Length'] + given_content_length = request_headers["Content-Length"] try: int(given_content_length) except ValueError as exc: - raise ContentLengthHeaderNotInt from exc + _LOGGER.warning(msg="The Content-Length header is not an integer.") + raise ContentLengthHeaderNotIntError from exc +@beartype def validate_content_length_header_not_too_large( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``Content-Length`` header is not too large. + """Validate the ``Content-Length`` header is not too large. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - ContentLengthHeaderTooLarge: The given content length header says that - the content length is greater than the body length. + ContentLengthHeaderTooLargeError: The given content length header says + that the content length is greater than the body length. """ - given_content_length = request_headers['Content-Length'] + given_content_length = request_headers["Content-Length"] - body_length = len(request_body if request_body else b'') + body_length = len(request_body) given_content_length_value = int(given_content_length) - if given_content_length_value > body_length: - raise ContentLengthHeaderTooLarge + # We skip coverage here as running a test to cover this is very slow. + if given_content_length_value > body_length: # pragma: no cover + _LOGGER.warning(msg="The Content-Length header is too large.") + raise ContentLengthHeaderTooLargeError +@beartype def validate_content_length_header_not_too_small( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``Content-Length`` header is not too small. + """Validate the ``Content-Length`` header is not too small. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - AuthenticationFailureGoodFormatting: The given content length header - says that the content length is smaller than the body length. + AuthenticationFailureGoodFormattingError: The given content length + header says that the content length is smaller than the body + length. """ - given_content_length = request_headers['Content-Length'] + given_content_length = request_headers["Content-Length"] - body_length = len(request_body if request_body else b'') + body_length = len(request_body) given_content_length_value = int(given_content_length) if given_content_length_value < body_length: - raise AuthenticationFailureGoodFormatting + _LOGGER.warning(msg="The Content-Length header is too small.") + raise AuthenticationFailureGoodFormattingError diff --git a/src/mock_vws/_query_validators/content_type_validators.py b/src/mock_vws/_query_validators/content_type_validators.py index 6321f1363..586978ccc 100644 --- a/src/mock_vws/_query_validators/content_type_validators.py +++ b/src/mock_vws/_query_validators/content_type_validators.py @@ -1,42 +1,65 @@ -""" -Validators for the ``Content-Type`` header. -""" +"""Validators for the ``Content-Type`` header.""" -import cgi -from typing import Dict +import logging +from collections.abc import Mapping +from email.message import EmailMessage + +from beartype import beartype from mock_vws._query_validators.exceptions import ( - BoundaryNotInBody, - NoBoundaryFound, - UnsupportedMediaType, + ImageNotGivenError, + NoBoundaryFoundError, + NoContentTypeError, + UnsupportedMediaTypeError, ) +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_content_type_header( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``Content-Type`` header. + """Validate the ``Content-Type`` header. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - UnsupportedMediaType: The ``Content-Type`` header main part is not + UnsupportedMediaTypeError: The ``Content-Type`` header main part is not 'multipart/form-data'. - NoBoundaryFound: The ``Content-Type`` header does not contain a + NoBoundaryFoundError: The ``Content-Type`` header does not contain a boundary. - BoundaryNotInBody: The boundary is not in the request body. + ImageNotGivenError: The boundary is not in the request body. + NoContentTypeError: The content type header is either empty or not + given. """ - content_type_header = request_headers.get('Content-Type', '') - main_value, pdict = cgi.parse_header(content_type_header) - if main_value != 'multipart/form-data': - raise UnsupportedMediaType + request_headers_dict = dict(request_headers) + content_type_header = request_headers_dict.get("Content-Type", "") + if not content_type_header: + _LOGGER.warning(msg="The content type header is empty.") + raise NoContentTypeError + + email_message = EmailMessage() + email_message["Content-Type"] = request_headers["Content-Type"] + if email_message.get_content_type() not in {"multipart/form-data", "*/*"}: + _LOGGER.warning( + msg=( + "The content type header main part is not multipart/form-data." + ), + ) + raise UnsupportedMediaTypeError - if 'boundary' not in pdict: - raise NoBoundaryFound + boundary = email_message.get_boundary() + if boundary is None: + _LOGGER.warning( + msg="The content type header does not contain a boundary.", + ) + raise NoBoundaryFoundError - if pdict['boundary'].encode() not in request_body: - raise BoundaryNotInBody + if boundary.encode() not in request_body: + _LOGGER.warning(msg="The boundary is not in the request body.") + raise ImageNotGivenError diff --git a/src/mock_vws/_query_validators/date_validators.py b/src/mock_vws/_query_validators/date_validators.py index 4080e124e..116aff3eb 100644 --- a/src/mock_vws/_query_validators/date_validators.py +++ b/src/mock_vws/_query_validators/date_validators.py @@ -1,100 +1,102 @@ -""" -Validators of the date header to use in the mock query API. -""" +"""Validators of the date header to use in the mock query API.""" +import contextlib import datetime -from typing import Dict, Set +import logging +from collections.abc import Mapping +from zoneinfo import ZoneInfo -from backports.zoneinfo import ZoneInfo +from beartype import beartype from mock_vws._query_validators.exceptions import ( - DateFormatNotValid, - DateHeaderNotGiven, - RequestTimeTooSkewed, + DateFormatNotValidError, + DateHeaderNotGivenError, + RequestTimeTooSkewedError, ) +_LOGGER = logging.getLogger(name=__name__) -def validate_date_header_given(request_headers: Dict[str, str]) -> None: - """ - Validate the date header is given to the query endpoint. + +@beartype +def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: + """Validate the date header is given to the query endpoint. Args: request_headers: The headers sent with the request. Raises: - DateHeaderNotGiven: The date is not given. + DateHeaderNotGivenError: The date is not given. """ - if 'Date' in request_headers: + if "Date" in request_headers: return - raise DateHeaderNotGiven + _LOGGER.warning(msg="The date header is not given.") + raise DateHeaderNotGivenError -def _accepted_date_formats() -> Set[str]: - """ - Return all known accepted date formats. +@beartype +def _accepted_date_formats() -> set[str]: + """Return all known accepted date formats. - We expect that more formats than this will be accepted. - These are the accepted ones we know of at the time of writing. + We expect that more formats than this will be accepted. These are + the accepted ones we know of at the time of writing. """ known_accepted_formats = { - '%a, %b %d %H:%M:%S %Y', - '%a %b %d %H:%M:%S %Y', - '%a, %d %b %Y %H:%M:%S', - '%a %d %b %Y %H:%M:%S', + "%a, %b %d %H:%M:%S %Y", + "%a %b %d %H:%M:%S %Y", + "%a, %d %b %Y %H:%M:%S", + "%a %d %b %Y %H:%M:%S", } - known_accepted_formats = known_accepted_formats.union( - set(date_format + ' GMT' for date_format in known_accepted_formats), + return known_accepted_formats.union( + {f"{date_format} GMT" for date_format in known_accepted_formats}, ) - return known_accepted_formats - -def validate_date_format(request_headers: Dict[str, str]) -> None: - """ - Validate the format of the date header given to the query endpoint. +@beartype +def validate_date_format(*, request_headers: Mapping[str, str]) -> None: + """Validate the format of the date header given to the query endpoint. Args: request_headers: The headers sent with the request. Raises: - DateFormatNotValid: The date is in the wrong format. + DateFormatNotValidError: The date is in the wrong format. """ - date_header = request_headers['Date'] + date_header = request_headers["Date"] for date_format in _accepted_date_formats(): - try: - datetime.datetime.strptime(date_header, date_format) - except ValueError: - pass - else: + with contextlib.suppress(ValueError): + datetime.datetime.strptime(date_header, date_format).astimezone() return - raise DateFormatNotValid + _LOGGER.warning(msg="The date header is in the wrong format.") + raise DateFormatNotValidError -def validate_date_in_range(request_headers: Dict[str, str]) -> None: - """ - Validate date in the date header given to the query endpoint. +@beartype +def validate_date_in_range(*, request_headers: Mapping[str, str]) -> None: + """Validate date in the date header given to the query endpoint. Args: request_headers: The headers sent with the request. Raises: - RequestTimeTooSkewed: The date is out of range. + RequestTimeTooSkewedError: The date is out of range. """ - date_header = request_headers['Date'] + date_header = request_headers["Date"] + gmt = ZoneInfo(key="GMT") + dates: list[datetime.datetime] = [] for date_format in _accepted_date_formats(): - try: - date = datetime.datetime.strptime(date_header, date_format) - # We could break here but that would give a coverage report that is - # not 100%. - except ValueError: - pass - - gmt = ZoneInfo('GMT') + with contextlib.suppress(ValueError): + date = datetime.datetime.strptime( + date_header, + date_format, + ).astimezone() + dates.append(date) + + date = dates[0] now = datetime.datetime.now(tz=gmt) date_from_header = date.replace(tzinfo=gmt) time_difference = now - date_from_header @@ -104,4 +106,5 @@ def validate_date_in_range(request_headers: Dict[str, str]) -> None: if abs(time_difference) < maximum_time_difference: return - raise RequestTimeTooSkewed + _LOGGER.warning(msg="The date header is out of range.") + raise RequestTimeTooSkewedError diff --git a/src/mock_vws/_query_validators/exceptions.py b/src/mock_vws/_query_validators/exceptions.py index a4cf34c5c..bb830651b 100644 --- a/src/mock_vws/_query_validators/exceptions.py +++ b/src/mock_vws/_query_validators/exceptions.py @@ -1,54 +1,94 @@ -""" -Exceptions to raise from validators. -""" +"""Exceptions to raise from validators.""" +import email.utils +import textwrap import uuid +from collections.abc import Mapping from http import HTTPStatus -from pathlib import Path + +from beartype import beartype from mock_vws._constants import ResultCodes from mock_vws._mock_common import json_dump -class DateHeaderNotGiven(Exception): +@beartype +class ValidatorError(Exception): """ - Exception raised when a date header is not given. + A base class for exceptions thrown from mock Vuforia cloud + recognition + client endpoints. """ + status_code: HTTPStatus + response_text: str + headers: Mapping[str, str] + + +@beartype +class DateHeaderNotGivenError(ValidatorError): + """Exception raised when a date header is not given.""" + def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = 'Date header required.' + self.response_text = "Date header required." + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "text/plain;charset=iso-8859-1", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class DateFormatNotValid(Exception): - """ - Exception raised when the date format is not valid. - """ +@beartype +class DateFormatNotValidError(ValidatorError): + """Exception raised when the date format is not valid.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.UNAUTHORIZED - self.response_text = 'Malformed date header.' + self.response_text = "Malformed date header." + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "text/plain;charset=iso-8859-1", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "WWW-Authenticate": "KWS", + "Content-Length": str(object=len(self.response_text)), + } -class RequestTimeTooSkewed(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class RequestTimeTooSkewedError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'RequestTimeTooSkewed'. """ @@ -57,21 +97,34 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.FORBIDDEN body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.REQUEST_TIME_TOO_SKEWED.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.REQUEST_TIME_TOO_SKEWED.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } - self.response_text = json_dump(body) -class BadImage(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class BadImageError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'BadImage'. """ @@ -80,7 +133,8 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() @@ -94,13 +148,26 @@ def __init__(self) -> None: '{"transaction_id": ' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" + ) + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class AuthenticationFailure(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class AuthenticationFailureError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. """ @@ -109,7 +176,8 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() @@ -123,13 +191,26 @@ def __init__(self) -> None: '{"transaction_id":' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" ) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "WWW-Authenticate": "VWS", + "Content-Length": str(object=len(self.response_text)), + } -class AuthenticationFailureGoodFormatting(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class AuthenticationFailureGoodFormattingError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure' with a standard JSON formatting. """ @@ -138,94 +219,163 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.UNAUTHORIZED body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.AUTHENTICATION_FAILURE.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.AUTHENTICATION_FAILURE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "WWW-Authenticate": "VWS", + "Content-Length": str(object=len(self.response_text)), } - self.response_text = json_dump(body) -class ImageNotGiven(Exception): - """ - Exception raised when an image is not given. - """ +@beartype +class ImageNotGivenError(ValidatorError): + """Exception raised when an image is not given.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = 'No image.' + self.response_text = "No image." + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class AuthHeaderMissing(Exception): - """ - Exception raised when an auth header is not given. - """ + +@beartype +class AuthHeaderMissingError(ValidatorError): + """Exception raised when an auth header is not given.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.UNAUTHORIZED - self.response_text = 'Authorization header missing.' + self.response_text = "Authorization header missing." + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "text/plain;charset=iso-8859-1", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "WWW-Authenticate": "KWS", + "Content-Length": str(object=len(self.response_text)), + } -class MalformedAuthHeader(Exception): - """ - Exception raised when an auth header is not given. - """ +@beartype +class MalformedAuthHeaderError(ValidatorError): + """Exception raised when an auth header is not given.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. + www_authenticate: The WWW-Authenticate header value. """ super().__init__() self.status_code = HTTPStatus.UNAUTHORIZED - self.response_text = 'Malformed authorization header.' + self.response_text = "Malformed authorization header." + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "text/plain;charset=iso-8859-1", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "WWW-Authenticate": "KWS", + "Content-Length": str(object=len(self.response_text)), + } -class UnknownParameters(Exception): - """ - Exception raised when unknown parameters are given. - """ + +@beartype +class UnknownParametersError(ValidatorError): + """Exception raised when unknown parameters are given.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = 'Unknown parameters in the request.' + self.response_text = "Unknown parameters in the request." + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class InactiveProject(Exception): - """ - Exception raised when Vuforia returns a response with a result code + +@beartype +class InactiveProjectError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'InactiveProject'. """ @@ -234,7 +384,8 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() @@ -247,14 +398,28 @@ def __init__(self) -> None: '{"transaction_id": ' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" + ) + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class InvalidMaxNumResults(Exception): - """ - Exception raised when an invalid value is given as the - "max_num_results" field. +@beartype +class InvalidMaxNumResultsError(ValidatorError): + """Exception raised when an invalid value is given as the + "max_num_results" + field. """ def __init__(self, given_value: str) -> None: @@ -262,21 +427,36 @@ def __init__(self, given_value: str) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST invalid_value_message = ( f"Invalid value '{given_value}' in form data part 'max_result'. " - 'Expecting integer value in range from 1 to 50 (inclusive).' + "Expecting integer value in range from 1 to 50 (inclusive)." ) self.response_text = invalid_value_message + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } + -class MaxNumResultsOutOfRange(Exception): - """ - Exception raised when an integer value is given as the "max_num_results" +@beartype +class MaxNumResultsOutOfRangeError(ValidatorError): + """Exception raised when an integer value is given as the + "max_num_results" field which is out of range. """ @@ -285,21 +465,35 @@ def __init__(self, given_value: str) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST integer_out_of_range_message = ( - f'Integer out of range ({given_value}) in form data part ' + f"Integer out of range ({given_value}) in form data part " "'max_result'. Accepted range is from 1 to 50 (inclusive)." ) self.response_text = integer_out_of_range_message + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class InvalidIncludeTargetData(Exception): - """ - Exception raised when an invalid value is given as the + +@beartype +class InvalidIncludeTargetDataError(ValidatorError): + """Exception raised when an invalid value is given as the "include_target_data" field. """ @@ -308,102 +502,156 @@ def __init__(self, given_value: str) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST unexpected_target_data_message = ( - f"Invalid value '{given_value}' in form data part " + f"Invalid value '{given_value.lower()}' in form data part " "'include_target_data'. " "Expecting one of the (unquoted) string values 'all', 'none' or " "'top'." ) self.response_text = unexpected_target_data_message + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class UnsupportedMediaType(Exception): - """ - Exception raised when no boundary is found for multipart data. - """ + +@beartype +class UnsupportedMediaTypeError(ValidatorError): + """Exception raised when no boundary is found for multipart data.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.UNSUPPORTED_MEDIA_TYPE - self.response_text = '' + self.response_text = "" + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class InvalidAcceptHeader(Exception): - """ - Exception raised when there is an invalid accept header given. - """ + +@beartype +class InvalidAcceptHeaderError(ValidatorError): + """Exception raised when there is an invalid accept header given.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.NOT_ACCEPTABLE - self.response_text = '' + self.response_text = "" + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class BoundaryNotInBody(Exception): - """ - Exception raised when the form boundary is not in the request body. - """ + +@beartype +class NoBoundaryFoundError(ValidatorError): + """Exception raised when an invalid media type is given.""" def __init__(self) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = ( - 'java.lang.RuntimeException: RESTEASY007500: ' - 'Could find no Content-Disposition header within part' + self.response_text = "Unable to get boundary for multipart" + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, ) + self.headers = { + "Content-Type": "text/plain;charset=utf-8", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } -class NoBoundaryFound(Exception): +@beartype +class ContentLengthHeaderTooLargeError(ValidatorError): """ - Exception raised when an invalid media type is given. + Exception raised when the given content length header is too + large. """ - def __init__(self) -> None: + # We skip coverage here as running a test to cover this is very slow. + def __init__(self) -> None: # pragma: no cover """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() - self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = ( - 'java.io.IOException: RESTEASY007550: ' - 'Unable to get boundary for multipart' - ) + self.status_code = HTTPStatus.GATEWAY_TIMEOUT + self.response_text = "" + self.headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(self.response_text)), + } -class QueryOutOfBounds(Exception): +@beartype +class ContentLengthHeaderNotIntError(ValidatorError): """ - Exception raised when VWS returns an HTML page which says that there is a - particular out of bounds error. + Exception raised when the given content length header is not an + integer. """ def __init__(self) -> None: @@ -411,39 +659,67 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() - self.status_code = HTTPStatus.INTERNAL_SERVER_ERROR - resources_dir = Path(__file__).parent / 'resources' - filename = 'query_out_of_bounds_response.html' - oops_resp_file = resources_dir / filename - text = str(oops_resp_file.read_text()) - self.response_text = text + self.status_code = HTTPStatus.BAD_REQUEST + self.response_text = "" + self.headers = { + "Connection": "Close", + "Content-Length": str(object=len(self.response_text)), + } -class ContentLengthHeaderTooLarge(Exception): - """ - Exception raised when the given content length header is too large. - """ +@beartype +class RequestEntityTooLargeError(ValidatorError): + """Exception raised when the given image file size is too large.""" - def __init__(self) -> None: + # Ignore coverage on this as there is a bug in urllib3 which means that we + # do not trigger this exception. + # See https://github.com/urllib3/urllib3/issues/2733. + def __init__(self) -> None: # pragma: no cover """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() - self.status_code = HTTPStatus.GATEWAY_TIMEOUT - self.response_text = '' + self.status_code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE + self.response_text = textwrap.dedent( + text="""\ + \r + 413 Request Entity Too Large\r + \r +

413 Request Entity Too Large

\r +
nginx
\r + \r + \r + """, + ) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "Close", + "Date": date, + "Server": "nginx", + "Content-Type": "text/html", + "Content-Length": str(object=len(self.response_text)), + } -class ContentLengthHeaderNotInt(Exception): +@beartype +class NoContentTypeError(ValidatorError): """ - Exception raised when the given content length header is not an integer. + Exception raised when a content type is either not given or is + empty. """ def __init__(self) -> None: @@ -451,9 +727,43 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = '' + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + jetty_content_type_error = textwrap.dedent( + text="""\ + + + + Error 400 Bad Request + + +

HTTP ERROR 400 Bad Request

+ + + + +
URI:http://cloudreco.vuforia.com/v1/query
STATUS:400
MESSAGE:Bad Request
+
Powered by Jetty:// 12.0.20
+ + + + """, # noqa: E501 + ) + self.response_text = jetty_content_type_error + self.headers = { + "Connection": "keep-alive", + "Content-Type": "text/html;charset=iso-8859-1", + "Server": "nginx", + "Cache-Control": "must-revalidate,no-cache,no-store", + "Date": date, + "Content-Length": str(object=len(self.response_text)), + } diff --git a/src/mock_vws/_query_validators/fields_validators.py b/src/mock_vws/_query_validators/fields_validators.py index f75c759b7..b9e78fecd 100644 --- a/src/mock_vws/_query_validators/fields_validators.py +++ b/src/mock_vws/_query_validators/fields_validators.py @@ -1,41 +1,47 @@ -""" -Validators for the fields given. -""" +"""Validators for the fields given.""" -import cgi import io -from typing import Dict +import logging +from collections.abc import Mapping +from email.message import EmailMessage -from mock_vws._query_validators.exceptions import UnknownParameters +from beartype import beartype +from werkzeug.formparser import MultiPartParser +from mock_vws._query_validators.exceptions import UnknownParametersError +_LOGGER = logging.getLogger(name=__name__) + + +@beartype def validate_extra_fields( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate that the no unknown fields are given. + """Validate that the no unknown fields are given. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - UnknownParameters: Extra fields are given. + UnknownParametersError: Extra fields are given. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + email_message = EmailMessage() + email_message["Content-Type"] = request_headers["Content-Type"] + boundary = email_message.get_boundary(failobj="") + parser = MultiPartParser() + fields, files = parser.parse( + stream=io.BytesIO(initial_bytes=request_body), + boundary=boundary.encode(encoding="utf-8"), + content_length=len(request_body), ) + parsed_keys = fields.keys() | files.keys() + known_parameters = {"image", "max_num_results", "include_target_data"} - known_parameters = {'image', 'max_num_results', 'include_target_data'} - - if not parsed.keys() - known_parameters: + if not parsed_keys - known_parameters: return - raise UnknownParameters + _LOGGER.warning(msg="Unknown parameters are given.") + raise UnknownParametersError diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index d1fa6c880..aa12caa94 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -1,185 +1,197 @@ -""" -Input validators for the image field use in the mock query API. -""" +"""Input validators for the image field use in the mock query API.""" -import cgi import io -from typing import Dict +import logging +from collections.abc import Mapping +from email.message import EmailMessage -import requests -from PIL import Image +from beartype import beartype +from werkzeug.datastructures import FileStorage, MultiDict +from werkzeug.formparser import MultiPartParser -from mock_vws._query_validators.exceptions import BadImage, ImageNotGiven +from mock_vws._image_opening import open_image +from mock_vws._query_validators.exceptions import ( + BadImageError, + ImageNotGivenError, + RequestEntityTooLargeError, +) +_LOGGER = logging.getLogger(name=__name__) + +@beartype +def _parse_multipart_files( + *, + request_headers: Mapping[str, str], + request_body: bytes, +) -> MultiDict[str, FileStorage]: + """Parse the multipart body and return the files section. + + Args: + request_headers: The headers sent with the request. + request_body: The body of the request. + + Returns: + The files parsed from the multipart body. + """ + email_message = EmailMessage() + email_message["Content-Type"] = request_headers["Content-Type"] + boundary = email_message.get_boundary(failobj="") + parser = MultiPartParser() + _, files = parser.parse( + stream=io.BytesIO(initial_bytes=request_body), + boundary=boundary.encode(encoding="utf-8"), + content_length=len(request_body), + ) + return files + + +@beartype def validate_image_field_given( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate that the image field is given. + """Validate that the image field is given. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - ImageNotGiven: The image field is not given. + ImageNotGivenError: The image field is not given. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) - - if 'image' in parsed.keys(): + if files.get(key="image") is not None: return - raise ImageNotGiven + _LOGGER.warning(msg="The image field is not given.") + raise ImageNotGivenError +@beartype def validate_image_file_size( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the file size of the image given to the query endpoint. + """Validate the file size of the image given to the query endpoint. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - requests.exceptions.ConnectionError: The image file size is too large. + RequestEntityTooLargeError: The image file size is too large. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) - - [image] = parsed['image'] + image_part = files["image"] + image_value = image_part.stream.read() # This is the documented maximum size of a PNG as per. - # https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. + # https://developer.vuforia.com/library/web-api/vuforia-query-web-api. # However, the tests show that this maximum size also applies to JPEG # files. max_bytes = 2 * 1024 * 1024 - if len(image) > max_bytes: - raise requests.exceptions.ConnectionError + # Ignore coverage on this as there is a bug in urllib3 which means that we + # do not trigger this exception. + # See https://github.com/urllib3/urllib3/issues/2733. + if len(image_value) > max_bytes: # pragma: no cover + _LOGGER.warning(msg="The image file size is too large.") + raise RequestEntityTooLargeError +@beartype def validate_image_dimensions( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the dimensions the image given to the query endpoint. + """Validate the dimensions the image given to the query endpoint. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - BadImage: The image is given and is not within the maximum width and - height limits. + BadImageError: The image is given and is not within the maximum width + and height limits. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) + image_part = files["image"] + image_value = image_part.stream.read() + image_file = io.BytesIO(initial_bytes=image_value) + with open_image(fp=image_file) as pil_image: + max_width = 30000 + max_height = 30000 + if pil_image.height <= max_height and pil_image.width <= max_width: + return - [image] = parsed['image'] - assert isinstance(image, bytes) - image_file = io.BytesIO(image) - pil_image = Image.open(image_file) - max_width = 30000 - max_height = 30000 - if pil_image.height <= max_height and pil_image.width <= max_width: - return - - raise BadImage + _LOGGER.warning(msg="The image dimensions are too large.") + raise BadImageError +@beartype def validate_image_format( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the format of the image given to the query endpoint. + """Validate the format of the image given to the query endpoint. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - BadImage: The image is given and is not either a PNG or a JPEG. + BadImageError: The image is given and is not either a PNG or a JPEG. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) + image_part = files["image"] + with open_image(fp=image_part.stream) as pil_image: + if pil_image.format in {"PNG", "JPEG"}: + return - [image] = parsed['image'] - - assert isinstance(image, bytes) - image_file = io.BytesIO(image) - pil_image = Image.open(image_file) - - if pil_image.format in ('PNG', 'JPEG'): - return - - raise BadImage + _LOGGER.warning(msg="The image format is not PNG or JPEG.") + raise BadImageError +@beartype def validate_image_is_image( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate that the given image data is actually an image file. + """Validate that the given image data is actually an image file. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - BadImage: Image data is given and it is not an image file. + BadImageError: Image data is given and it is not an image file. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + files = _parse_multipart_files( + request_headers=request_headers, + request_body=request_body, ) - - [image] = parsed['image'] - - assert isinstance(image, bytes) - image_file = io.BytesIO(image) + image_file = files["image"].stream try: - Image.open(image_file) + with open_image(fp=image_file) as _: + pass except OSError as exc: - raise BadImage from exc + _LOGGER.warning(msg="The image is not an image file.") + raise BadImageError from exc diff --git a/src/mock_vws/_query_validators/include_target_data_validators.py b/src/mock_vws/_query_validators/include_target_data_validators.py index e49af4b6e..b3696c658 100644 --- a/src/mock_vws/_query_validators/include_target_data_validators.py +++ b/src/mock_vws/_query_validators/include_target_data_validators.py @@ -1,45 +1,51 @@ -""" -Validators for the ``include_target_data`` field. -""" +"""Validators for the ``include_target_data`` field.""" -import cgi import io -from typing import Dict +import logging +from collections.abc import Mapping +from email.message import EmailMessage -from mock_vws._query_validators.exceptions import InvalidIncludeTargetData +from beartype import beartype +from werkzeug.formparser import MultiPartParser +from mock_vws._query_validators.exceptions import InvalidIncludeTargetDataError +_LOGGER = logging.getLogger(name=__name__) + + +@beartype def validate_include_target_data( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``include_target_data`` field is either an accepted value or - not given. + """Validate the ``include_target_data`` field is either an accepted + value + or not given. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - InvalidIncludeTargetData: The ``include_target_data`` field is not an - accepted value. + InvalidIncludeTargetDataError: The ``include_target_data`` field is not + an accepted value. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + email_message = EmailMessage() + email_message["Content-Type"] = request_headers["Content-Type"] + boundary = email_message.get_boundary(failobj="") + parser = MultiPartParser() + fields, _ = parser.parse( + stream=io.BytesIO(initial_bytes=request_body), + boundary=boundary.encode(encoding="utf-8"), + content_length=len(request_body), ) - - [include_target_data] = parsed.get('include_target_data', ['top']) - lower_include_target_data = include_target_data.lower() - allowed_included_target_data = {'top', 'all', 'none'} - if lower_include_target_data in allowed_included_target_data: + include_target_data = fields.get(key="include_target_data", default="top") + allowed_included_target_data = {"top", "all", "none"} + if include_target_data.lower() in allowed_included_target_data: return - assert isinstance(include_target_data, str) - raise InvalidIncludeTargetData(given_value=include_target_data) + _LOGGER.warning( + msg="The include_target_data field is not an accepted value.", + ) + raise InvalidIncludeTargetDataError(given_value=include_target_data) diff --git a/src/mock_vws/_query_validators/num_results_validators.py b/src/mock_vws/_query_validators/num_results_validators.py index d3b819317..31ead620b 100644 --- a/src/mock_vws/_query_validators/num_results_validators.py +++ b/src/mock_vws/_query_validators/num_results_validators.py @@ -1,54 +1,64 @@ -""" -Validators for the ``max_num_results`` fields. -""" +"""Validators for the ``max_num_results`` fields.""" -import cgi import io -from typing import Dict +import logging +from collections.abc import Mapping +from email.message import EmailMessage + +from beartype import beartype +from werkzeug.formparser import MultiPartParser from mock_vws._query_validators.exceptions import ( - InvalidMaxNumResults, - MaxNumResultsOutOfRange, + InvalidMaxNumResultsError, + MaxNumResultsOutOfRangeError, ) +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_max_num_results( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``max_num_results`` field is either an integer within range or - not given. + """Validate the ``max_num_results`` field is either an integer within + range + or not given. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - InvalidMaxNumResults: The ``max_num_results`` given is not an integer - less than or equal to the max integer in Java. - MaxNumResultsOutOfRange: The ``max_num_results`` given is not in range. + InvalidMaxNumResultsError: The ``max_num_results`` given is not an + integer less than or equal to the max integer in Java. + MaxNumResultsOutOfRangeError: The ``max_num_results`` given is not in + range. """ - body_file = io.BytesIO(request_body) - - _, pdict = cgi.parse_header(request_headers['Content-Type']) - parsed = cgi.parse_multipart( - fp=body_file, - pdict={ - 'boundary': pdict['boundary'].encode(), - }, + email_message = EmailMessage() + email_message["Content-Type"] = request_headers["Content-Type"] + boundary = email_message.get_boundary(failobj="") + parser = MultiPartParser() + fields, _ = parser.parse( + stream=io.BytesIO(initial_bytes=request_body), + boundary=boundary.encode(encoding="utf-8"), + content_length=len(request_body), ) - [max_num_results] = parsed.get('max_num_results', ['1']) - assert isinstance(max_num_results, str) + max_num_results = fields.get(key="max_num_results", default="1") try: max_num_results_int = int(max_num_results) except ValueError as exc: - raise InvalidMaxNumResults(given_value=max_num_results) from exc + _LOGGER.warning(msg="The max_num_results field is not an integer.") + raise InvalidMaxNumResultsError(given_value=max_num_results) from exc java_max_int = 2147483647 if max_num_results_int > java_max_int: - raise InvalidMaxNumResults(given_value=max_num_results) + _LOGGER.warning(msg="The max_num_results field is too large.") + raise InvalidMaxNumResultsError(given_value=max_num_results) - if max_num_results_int < 1 or max_num_results_int > 50: - raise MaxNumResultsOutOfRange(given_value=max_num_results) + max_allowed_results = 50 + if max_num_results_int < 1 or max_num_results_int > max_allowed_results: + _LOGGER.warning(msg="The max_num_results field is out of range.") + raise MaxNumResultsOutOfRangeError(given_value=max_num_results) diff --git a/src/mock_vws/_query_validators/project_state_validators.py b/src/mock_vws/_query_validators/project_state_validators.py index 139afa2e1..7767499b2 100644 --- a/src/mock_vws/_query_validators/project_state_validators.py +++ b/src/mock_vws/_query_validators/project_state_validators.py @@ -1,24 +1,28 @@ -""" -Validators for the project state. -""" +"""Validators for the project state.""" -from typing import Dict, Set +import logging +from collections.abc import Iterable, Mapping + +from beartype import beartype from mock_vws._database_matchers import get_database_matching_client_keys -from mock_vws._query_validators.exceptions import InactiveProject -from mock_vws.database import VuforiaDatabase +from mock_vws._query_validators.exceptions import InactiveProjectError +from mock_vws.database import CloudDatabase from mock_vws.states import States +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_project_state( + *, request_path: str, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Set[VuforiaDatabase], + databases: Iterable[CloudDatabase], ) -> None: - """ - Validate the state of the project. + """Validate the state of the project. Args: request_path: The path of the request. @@ -28,7 +32,7 @@ def validate_project_state( databases: All Vuforia databases. Raises: - InactiveProject: The project is inactive. + InactiveProjectError: The project is inactive. """ database = get_database_matching_client_keys( request_headers=request_headers, @@ -38,8 +42,8 @@ def validate_project_state( databases=databases, ) - assert isinstance(database, VuforiaDatabase) if database.state != States.PROJECT_INACTIVE: return - raise InactiveProject + _LOGGER.warning(msg="The project is inactive.") + raise InactiveProjectError diff --git a/src/mock_vws/_query_validators/resources/query_out_of_bounds_response.html b/src/mock_vws/_query_validators/resources/query_out_of_bounds_response.html deleted file mode 100644 index 7a97a1674..000000000 --- a/src/mock_vws/_query_validators/resources/query_out_of_bounds_response.html +++ /dev/null @@ -1,34 +0,0 @@ - - - -Error 500 Server Error - -

HTTP ERROR 500

-

Problem accessing /v1/query. Reason: -

    Server Error

Caused by:

java.lang.ArrayIndexOutOfBoundsException: 1
-	at com.kooaba.queryservice.auth.KWSAuthFilter.doFilter(KWSAuthFilter.java:81)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652)
-	at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:585)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
-	at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:577)
-	at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:223)
-	at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1127)
-	at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:515)
-	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:185)
-	at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1061)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
-	at org.eclipse.jetty.server.handler.ContextHandlerCollection.handle(ContextHandlerCollection.java:215)
-	at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:110)
-	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:97)
-	at org.eclipse.jetty.server.Server.handle(Server.java:497)
-	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:310)
-	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:257)
-	at org.eclipse.jetty.io.AbstractConnection$2.run(AbstractConnection.java:540)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:635)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:555)
-	at java.lang.Thread.run(Thread.java:748)
-
-
Powered by Jetty://
- - - diff --git a/src/mock_vws/_reco_counts_web_api.py b/src/mock_vws/_reco_counts_web_api.py new file mode 100644 index 000000000..08d0d5e64 --- /dev/null +++ b/src/mock_vws/_reco_counts_web_api.py @@ -0,0 +1,185 @@ +"""A fake implementation of the Vuforia reco counts report endpoints.""" + +import datetime +import email.utils +import json +import logging +import re +import uuid +from http import HTTPStatus +from typing import Any, Protocol, runtime_checkable +from zoneinfo import ZoneInfo + +from beartype import beartype + +from mock_vws._constants import ResultCodes +from mock_vws._mock_common import json_dump +from mock_vws._services_validators.exceptions import FailError +from mock_vws.reco_counts import RecoCountsReport + +_ResponseType = tuple[int, dict[str, str], str | bytes] +_LOGGER = logging.getLogger(name=__name__) +_MONTH_PATTERN = re.compile(pattern=r"[0-9]{4}-[0-9]{2}") + + +@runtime_checkable +class RecoCountsReportStore(Protocol): + """Storage for generated reco counts reports.""" + + @property + def reco_counts_reports(self) -> dict[str, RecoCountsReport]: + """All reco counts reports, keyed by report identifier.""" + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + def add_reco_counts_report( + self, + reco_counts_report: RecoCountsReport, + ) -> None: + """Add a reco counts report.""" + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + +@beartype +def _headers(*, content_type: str, content_length: int) -> dict[str, str]: + """Return response headers which match other VWS endpoints.""" + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + return { + "Connection": "keep-alive", + "Content-Length": str(object=content_length), + "Content-Type": content_type, + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +def _download_headers( + *, content_type: str, content_length: int +) -> dict[str, str]: + """Return response headers for a report download. + + Real Vuforia serves reports from cloud storage, so these do not match the + headers of the VWS API. + """ + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + return { + "Content-Length": str(object=content_length), + "Content-Type": content_type, + "Date": date, + } + + +@beartype +def _months_in_range() -> set[str]: + """Return the months which a report can be requested for. + + Only the current month and the previous month can be requested. + """ + now = datetime.datetime.now(tz=ZoneInfo(key="UTC")) + first_of_month = now.replace(day=1) + last_of_previous_month = first_of_month - datetime.timedelta(days=1) + return { + now.strftime(format="%Y-%m"), + last_of_previous_month.strftime(format="%Y-%m"), + } + + +@beartype +def create_reco_counts_report( + *, + request_body: bytes, + report_store: RecoCountsReportStore, + generation_time_seconds: float, + base_url: str, +) -> _ResponseType: + """Request a reco counts report for a database. + + Args: + request_body: The body of the request. + report_store: The store which holds generated reports. + generation_time_seconds: The number of seconds before a generated + report is available to download. + base_url: The base URL to serve the generated report from. + + Returns: + A response which includes a URL to download the report from. + + Raises: + FailError: The given month is not a month in the ``YYYY-mm`` form + which the report can be requested for. + """ + request_json: dict[str, Any] = json.loads(s=request_body) + month = request_json["month"] + if not isinstance(month, str) or not _MONTH_PATTERN.fullmatch( + string=month, + ): + _LOGGER.warning(msg='The given "month" is not in the YYYY-mm form.') + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + + if month not in _months_in_range(): + _LOGGER.warning( + msg=( + 'The given "month" is not the current month or the previous ' + "month." + ), + ) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + + report = RecoCountsReport( + generation_time_seconds=generation_time_seconds, + ) + report_store.add_reco_counts_report(reco_counts_report=report) + + body = { + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "presigned_url": f"{base_url}/reports/recoCounts/{report.uuid_}", + } + body_json = json_dump(body=body) + headers = _headers( + content_type="application/json", + content_length=len(body_json), + ) + return HTTPStatus.OK, headers, body_json + + +@beartype +def download_reco_counts_report( + *, + report_store: RecoCountsReportStore, + report_id: str, +) -> _ResponseType: + """Download a generated reco counts report. + + Args: + report_store: The store which holds generated reports. + report_id: The identifier of the report to download. + + Returns: + The CSV content of the report, or a 404 response while the report is + not ready. + """ + report = report_store.reco_counts_reports.get(report_id) + if report is None or not report.is_available: + return ( + HTTPStatus.NOT_FOUND, + _download_headers(content_type="text/plain", content_length=0), + "", + ) + + body = report.csv_content + # Real Vuforia serves the report from S3 with a ``text/plain`` content + # type, not ``text/csv``. + headers = _download_headers( + content_type="text/plain", + content_length=len(body), + ) + return HTTPStatus.OK, headers, body diff --git a/src/mock_vws/_requests_mock_server/__init__.py b/src/mock_vws/_requests_mock_server/__init__.py index 17e43c879..79758e3ce 100644 --- a/src/mock_vws/_requests_mock_server/__init__.py +++ b/src/mock_vws/_requests_mock_server/__init__.py @@ -1,3 +1 @@ -""" -An interface to the mock Vuforia which uses ``requests_mock``. -""" +"""An interface to the mock Vuforia which uses ``responses``.""" diff --git a/src/mock_vws/_requests_mock_server/decorators.py b/src/mock_vws/_requests_mock_server/decorators.py deleted file mode 100644 index 24d43991d..000000000 --- a/src/mock_vws/_requests_mock_server/decorators.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -Decorators for using the mock. -""" - -import re -from contextlib import ContextDecorator -from typing import Literal, Tuple, Union -from urllib.parse import urljoin, urlparse - -import requests -from requests_mock.mocker import Mocker - -from mock_vws.database import VuforiaDatabase - -from .mock_web_query_api import MockVuforiaWebQueryAPI -from .mock_web_services_api import MockVuforiaWebServicesAPI - - -class MockVWS(ContextDecorator): - """ - Route requests to Vuforia's Web Service APIs to fakes of those APIs. - """ - - def __init__( # pylint: disable=too-many-arguments - self, - base_vws_url: str = 'https://vws.vuforia.com', - base_vwq_url: str = 'https://cloudreco.vuforia.com', - real_http: bool = False, - processing_time_seconds: Union[int, float] = 0.5, - query_recognizes_deletion_seconds: Union[int, float] = 0.2, - query_processes_deletion_seconds: Union[int, float] = 3, - ) -> None: - """ - Route requests to Vuforia's Web Service APIs to fakes of those APIs. - - Args: - real_http: Whether or not to forward requests to the real server if - they are not handled by the mock. - See - https://requests-mock.readthedocs.io/en/latest/mocker.html#real-http-requests. - processing_time_seconds: The number of seconds to process each - image for. In the real Vuforia Web Services, this is not - deterministic. - base_vwq_url: The base URL for the VWQ API. - base_vws_url: The base URL for the VWS API. - query_recognizes_deletion_seconds: The number of seconds after a - target has been deleted that the query endpoint will still - recognize the target for. - query_processes_deletion_seconds: The number of seconds after a - target deletion is recognized that the query endpoint will - return a 500 response on a match. - - Raises: - requests.exceptions.MissingSchema: There is no schema in a given - URL. - """ - super().__init__() - self._real_http = real_http - self._mock: Mocker - - self._base_vws_url = base_vws_url - self._base_vwq_url = base_vwq_url - missing_scheme_error = ( - 'Invalid URL "{url}": No scheme supplied. ' - 'Perhaps you meant "https://{url}".' - ) - for url in (base_vwq_url, base_vws_url): - result = urlparse(url) - if not result.scheme: - error = missing_scheme_error.format(url=url) - raise requests.exceptions.MissingSchema(error) - - self._mock_vws_api = MockVuforiaWebServicesAPI( - processing_time_seconds=processing_time_seconds, - ) - - self._mock_vwq_api = MockVuforiaWebQueryAPI( - query_processes_deletion_seconds=( - query_processes_deletion_seconds - ), - query_recognizes_deletion_seconds=( - query_recognizes_deletion_seconds - ), - ) - - def add_database(self, database: VuforiaDatabase) -> None: - """ - Add a cloud database. - - Args: - database: The database to add. - - Raises: - ValueError: One of the given database keys matches a key for an - existing database. - """ - message_fmt = ( - 'All {key_name}s must be unique. ' - 'There is already a database with the {key_name} "{value}".' - ) - for existing_db in self._mock_vws_api.databases: - for existing, new, key_name in ( - ( - existing_db.server_access_key, - database.server_access_key, - 'server access key', - ), - ( - existing_db.server_secret_key, - database.server_secret_key, - 'server secret key', - ), - ( - existing_db.client_access_key, - database.client_access_key, - 'client access key', - ), - ( - existing_db.client_secret_key, - database.client_secret_key, - 'client secret key', - ), - ): - if existing == new: - message = message_fmt.format(key_name=key_name, value=new) - raise ValueError(message) - - self._mock_vws_api.databases.add(database) - self._mock_vwq_api.databases.add(database) - - def __enter__(self) -> 'MockVWS': - """ - Start an instance of a Vuforia mock. - - Returns: - ``self``. - """ - headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - } - - with Mocker(real_http=self._real_http) as mock: - for route in self._mock_vws_api.routes: - url_pattern = urljoin( - base=self._base_vws_url, - url=route.path_pattern + '$', - ) - - for http_method in route.http_methods: - mock.register_uri( - method=http_method, - url=re.compile(url_pattern), - text=getattr(self._mock_vws_api, route.route_name), - headers=headers, - ) - - for route in self._mock_vwq_api.routes: - url_pattern = urljoin( - base=self._base_vwq_url, - url=route.path_pattern + '$', - ) - - for http_method in route.http_methods: - mock.register_uri( - method=http_method, - url=re.compile(url_pattern), - text=getattr(self._mock_vwq_api, route.route_name), - headers=headers, - ) - - self._mock = mock - self._mock.start() - - return self - - def __exit__(self, *exc: Tuple[None, None, None]) -> Literal[False]: - """ - Stop the Vuforia mock. - - Returns: - False - """ - # __exit__ needs this to be passed in but vulture thinks that it is - # unused, so we "use" it here. - for _ in (exc,): - pass - - self._mock.stop() - return False diff --git a/src/mock_vws/_requests_mock_server/mock_web_query_api.py b/src/mock_vws/_requests_mock_server/mock_web_query_api.py index f3ff80b63..74dd24ade 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_query_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_query_api.py @@ -1,151 +1,52 @@ -""" -A fake implementation of the Vuforia Web Query API. +"""A fake implementation of the Vuforia Web Query API. See -https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query +https://developer.vuforia.com/library/web-api/vuforia-query-web-api """ -from http import HTTPStatus -from pathlib import Path -from typing import Any, Callable, Dict, Set, Tuple, Union +import email.utils +from collections.abc import Callable, Iterable, Mapping +from http import HTTPMethod, HTTPStatus +from typing import ParamSpec, Protocol, runtime_checkable -import wrapt -from requests_mock import POST -from requests_mock.request import _RequestObjectProxy -from requests_mock.response import _Context +from beartype import beartype -from mock_vws._mock_common import ( - Route, - set_content_length_header, - set_date_header, -) +from mock_vws._mock_common import RequestData, Route from mock_vws._query_tools import ( - ActiveMatchingTargetsDeleteProcessing, - MatchingTargetsWithProcessingStatus, get_query_match_response_text, ) from mock_vws._query_validators import run_query_validators from mock_vws._query_validators.exceptions import ( - AuthenticationFailure, - AuthenticationFailureGoodFormatting, - AuthHeaderMissing, - BadImage, - BoundaryNotInBody, - ContentLengthHeaderNotInt, - ContentLengthHeaderTooLarge, - DateFormatNotValid, - DateHeaderNotGiven, - ImageNotGiven, - InactiveProject, - InvalidAcceptHeader, - InvalidIncludeTargetData, - InvalidMaxNumResults, - MalformedAuthHeader, - MaxNumResultsOutOfRange, - NoBoundaryFound, - QueryOutOfBounds, - RequestTimeTooSkewed, - UnknownParameters, - UnsupportedMediaType, + ValidatorError, ) -from mock_vws.database import VuforiaDatabase +from mock_vws.cloud_query import CloudQueryFailureResponse +from mock_vws.image_matchers import ImageMatcher +from mock_vws.target_manager import TargetManager -ROUTES = set([]) +_ROUTES: set[Route] = set() +_ResponseType = tuple[int, Mapping[str, str], str | bytes] +_P = ParamSpec("_P") -@wrapt.decorator -def run_validators( - wrapped: Callable[..., str], - instance: Any, - args: Tuple[_RequestObjectProxy, _Context], - kwargs: Dict, -) -> str: - """ - Run all validators for the query endpoint. - Args: - wrapped: An endpoint function for `requests_mock`. - instance: The class that the endpoint function is in. - args: The arguments given to the endpoint function. - kwargs: The keyword arguments given to the endpoint function. +@runtime_checkable +class _RouteMethod(Protocol[_P]): + """Callable used for routing which also exposes ``__name__``.""" - Returns: - The result of calling the endpoint. - """ - request, context = args - try: - run_query_validators( - request_path=request.path, - request_headers=request.headers, - request_body=request.body, - request_method=request.method, - databases=instance.databases, - ) - except DateHeaderNotGiven as exc: - content_type = 'text/plain; charset=ISO-8859-1' - context.headers['Content-Type'] = content_type - context.status_code = exc.status_code - return exc.response_text - except ( - AuthHeaderMissing, - DateFormatNotValid, - MalformedAuthHeader, - ) as exc: - content_type = 'text/plain; charset=ISO-8859-1' - context.headers['Content-Type'] = content_type - context.headers['WWW-Authenticate'] = 'VWS' - context.status_code = exc.status_code - return exc.response_text - except (AuthenticationFailure, AuthenticationFailureGoodFormatting) as exc: - context.headers['WWW-Authenticate'] = 'VWS' - context.status_code = exc.status_code - return exc.response_text - except ( - RequestTimeTooSkewed, - ImageNotGiven, - UnknownParameters, - InactiveProject, - InvalidIncludeTargetData, - InvalidMaxNumResults, - MaxNumResultsOutOfRange, - BadImage, - ) as exc: - context.status_code = exc.status_code - return exc.response_text - except (UnsupportedMediaType, InvalidAcceptHeader) as exc: - context.headers.pop('Content-Type') - context.status_code = exc.status_code - return exc.response_text - except (NoBoundaryFound, BoundaryNotInBody) as exc: - content_type = 'text/html;charset=UTF-8' - context.headers['Content-Type'] = content_type - context.status_code = exc.status_code - return exc.response_text - except QueryOutOfBounds as exc: - content_type = 'text/html; charset=ISO-8859-1' - context.headers['Content-Type'] = content_type - cache_control = 'must-revalidate,no-cache,no-store' - context.headers['Cache-Control'] = cache_control - context.status_code = exc.status_code - return exc.response_text - except ContentLengthHeaderNotInt as exc: - context.headers = {'Connection': 'Close'} - context.status_code = exc.status_code - return exc.response_text - except ContentLengthHeaderTooLarge as exc: - context.headers = {'Connection': 'keep-alive'} - context.status_code = exc.status_code - return exc.response_text + __name__: str - return wrapped(*args, **kwargs) + def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _ResponseType: + """Return a mock response.""" + ... # pylint: disable=unnecessary-ellipsis +@beartype def route( + *, path_pattern: str, - http_methods: Set[str], -) -> Callable[..., Callable]: - """ - Register a decorated method so that it can be recognized as a route. + http_methods: Iterable[str], +) -> Callable[[_RouteMethod[_P]], _RouteMethod[_P]]: + """Register a decorated method so that it can be recognized as a route. Args: path_pattern: The end part of a URL pattern. E.g. `/targets` or @@ -156,115 +57,95 @@ def route( A decorator which takes methods and makes them recognizable as routes. """ - def decorator(method: Callable[..., str]) -> Callable[..., str]: - """ - Register a decorated method so that it can be recognized as a route. + def decorator( + method: _RouteMethod[_P], + ) -> _RouteMethod[_P]: + """Register a decorated method so that it can be recognized as a + route. Returns: The given `method` with multiple changes, including added validators. """ - ROUTES.add( - Route( - route_name=method.__name__, - path_pattern=path_pattern, - http_methods=frozenset(http_methods), - ), + new_route = Route( + route_name=method.__name__, + path_pattern=path_pattern, + http_methods=frozenset(http_methods), ) - - decorators = [ - run_validators, - set_date_header, - set_content_length_header, - ] - - for decorator in decorators: - # See https://github.com/PyCQA/pylint/issues/259 - method = decorator( # pylint: disable=no-value-for-parameter - method, - ) + _ROUTES.add(new_route) return method return decorator +@beartype class MockVuforiaWebQueryAPI: - """ - A fake implementation of the Vuforia Web Query API. - - This implementation is tied to the implementation of `requests_mock`. - """ + """A fake implementation of the Vuforia Web Query API.""" def __init__( self, - query_recognizes_deletion_seconds: Union[int, float], - query_processes_deletion_seconds: Union[int, float], + target_manager: TargetManager, + query_match_checker: ImageMatcher, + failure_response: CloudQueryFailureResponse | None, ) -> None: """ Args: - query_recognizes_deletion_seconds: The number of seconds after a - target has been deleted that the query endpoint will still - recognize the target for. - query_processes_deletion_seconds: The number of seconds after a - target deletion is recognized that the query endpoint will - return a 500 response on a match. + target_manager: The target manager which holds all databases. + query_match_checker: A callable which takes two image values + and + returns whether they match. + failure_response: A configured failure response which takes + precedence over normal query handling. Attributes: routes: The `Route`s to be used in the mock. - databases: Target databases. """ - self.routes: Set[Route] = ROUTES - self.databases: Set[VuforiaDatabase] = set([]) - self._query_processes_deletion_seconds = ( - query_processes_deletion_seconds - ) - self._query_recognizes_deletion_seconds = ( - query_recognizes_deletion_seconds - ) + self.routes = _ROUTES + self._target_manager = target_manager + self._query_match_checker = query_match_checker + self._failure_response = failure_response + + @route(path_pattern="/v1/query", http_methods={HTTPMethod.POST}) + def query(self, request: RequestData) -> _ResponseType: + """Perform an image recognition query.""" + if self._failure_response is not None: + return ( + self._failure_response.status_code, + self._failure_response.headers, + self._failure_response.body, + ) - @route(path_pattern='/v1/query', http_methods={POST}) - def query( - self, - request: _RequestObjectProxy, - context: _Context, - ) -> str: - """ - Perform an image recognition query. - """ try: - response_text = get_query_match_response_text( + run_query_validators( + request_path=request.path, request_headers=request.headers, request_body=request.body, request_method=request.method, - request_path=request.path, - databases=self.databases, - query_processes_deletion_seconds=( - self._query_processes_deletion_seconds - ), - query_recognizes_deletion_seconds=( - self._query_recognizes_deletion_seconds - ), + databases=self._target_manager.cloud_databases, ) - except ( - ActiveMatchingTargetsDeleteProcessing, - MatchingTargetsWithProcessingStatus, - ): - # We return an example 500 response. - # Each response given by Vuforia is different. - # - # Sometimes Vuforia will ignore matching targets with the - # processing status, but we choose to: - # * Do the most unexpected thing. - # * Be consistent with every response. - resources_dir = Path(__file__).parent.parent / 'resources' - filename = 'match_processing_response.html' - match_processing_resp_file = resources_dir / filename - context.status_code = HTTPStatus.INTERNAL_SERVER_ERROR - cache_control = 'must-revalidate,no-cache,no-store' - context.headers['Cache-Control'] = cache_control - content_type = 'text/html; charset=ISO-8859-1' - context.headers['Content-Type'] = content_type - return Path(match_processing_resp_file).read_text() + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + + response_text = get_query_match_response_text( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + query_match_checker=self._query_match_checker, + ) - return response_text + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(response_text)), + } + return HTTPStatus.OK, headers, response_text diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 08674d514..0fdc7f5d5 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -1,675 +1,1070 @@ -""" -A fake implementation of the Vuforia Web Services API. +"""A fake implementation of the Vuforia Web Services API. See -https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API +https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api """ import base64 +import copy import datetime -import io -import itertools -import random +import email.utils +import json import uuid -from http import HTTPStatus -from typing import Any, Callable, Dict, List, Set, Tuple, Union - -import wrapt -from backports.zoneinfo import ZoneInfo -from PIL import Image -from requests_mock import DELETE, GET, POST, PUT -from requests_mock.request import _RequestObjectProxy -from requests_mock.response import _Context - -from mock_vws._constants import ResultCodes, TargetStatuses +from collections.abc import Callable, Iterable, Mapping +from http import HTTPMethod, HTTPStatus +from typing import TYPE_CHECKING, Any, ParamSpec, Protocol, runtime_checkable +from zoneinfo import ZoneInfo + +from beartype import BeartypeConf, beartype + +from mock_vws._constants import ( + VUMARK_PDF, + VUMARK_PNG, + VUMARK_SVG, + ResultCodes, + TargetStatuses, +) from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._mock_common import ( + RECO_COUNTS_DOWNLOAD_PATH_PATTERN, + RECO_COUNTS_REPORT_PATH_PATTERN, + RequestData, Route, json_dump, - set_content_length_header, - set_date_header, + sorted_targets, +) +from mock_vws._model_target_web_api import ( + create_model_target_dataset, + delete_model_target_dataset, + download_model_target_dataset, + get_model_target_dataset_status, + oauth2_token, +) +from mock_vws._reco_counts_web_api import ( + create_reco_counts_report, + download_reco_counts_report, ) from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( - AuthenticationFailure, - BadImage, - ContentLengthHeaderNotInt, - ContentLengthHeaderTooLarge, - Fail, - ImageTooLarge, - MetadataTooLarge, - OopsErrorOccurredResponse, - ProjectInactive, - RequestTimeTooSkewed, - TargetNameExist, - UnknownTarget, - UnnecessaryRequestBody, + FailError, + InvalidAcceptHeaderError, + InvalidTargetTypeError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + ValidatorError, +) +from mock_vws.database import VuMarkDatabase +from mock_vws.image_matchers import ImageMatcher +from mock_vws.model_target import ( + ModelTargetDatasetType, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, ) -from mock_vws.database import VuforiaDatabase -from mock_vws.target import Target +from mock_vws.target import ImageTarget +from mock_vws.target_manager import TargetManager +from mock_vws.target_raters import TargetTrackingRater +from mock_vws.vumark import VuMarkGenerationFailure -_TARGET_ID_PATTERN = '[A-Za-z0-9]+' +if TYPE_CHECKING: + from mock_vws.database import CloudDatabase +_TARGET_ID_PATTERN = "[A-Za-z0-9]+" +_MODEL_TARGET_DATASET_UUID_PATTERN = "[A-Za-z0-9-]+" -@wrapt.decorator -def update_request_count( - wrapped: Callable[..., str], - instance: Any, - args: Tuple[_RequestObjectProxy, _Context], - kwargs: Dict, -) -> str: - """ - Add to the request count. - Args: - wrapped: An endpoint function for `requests_mock`. - instance: The class that the endpoint function is in. - args: The arguments given to the endpoint function. - kwargs: The keyword arguments given to the endpoint function. +_ROUTES: set[Route] = set() - Returns: - The result of calling the endpoint. - """ - instance.request_count += 1 - return wrapped(*args, **kwargs) +_ResponseType = tuple[int, Mapping[str, str], str | bytes] +_P = ParamSpec("_P") -@wrapt.decorator -def run_validators( - wrapped: Callable[..., str], - instance: Any, - args: Tuple[_RequestObjectProxy, _Context], - kwargs: Dict, -) -> str: - """ - Send a relevant response if any validator raises an exception. +@runtime_checkable +class _RouteMethod(Protocol[_P]): + """Callable used for routing which also exposes ``__name__``.""" - Args: - wrapped: An endpoint function for `requests_mock`. - instance: The class that the endpoint function is in. - args: The arguments given to the endpoint function. - kwargs: The keyword arguments given to the endpoint function. + __name__: str - Returns: - The result of calling the endpoint. - """ - request, context = args - try: - run_services_validators( - request_headers=request.headers, - request_body=request.body, - request_method=request.method, - request_path=request.path, - databases=instance.databases, - ) - except ( - UnknownTarget, - ProjectInactive, - AuthenticationFailure, - Fail, - MetadataTooLarge, - TargetNameExist, - BadImage, - ImageTooLarge, - RequestTimeTooSkewed, - ) as exc: - context.status_code = exc.status_code - return exc.response_text - except OopsErrorOccurredResponse as exc: - content_type = 'text/html; charset=UTF-8' - context.headers['Content-Type'] = content_type - context.status_code = exc.status_code - return exc.response_text - except ContentLengthHeaderTooLarge as exc: - context.headers = {'Connection': 'keep-alive'} - context.status_code = exc.status_code - return exc.response_text - except ContentLengthHeaderNotInt as exc: - context.headers = {'Connection': 'Close'} - context.status_code = exc.status_code - return exc.response_text - except UnnecessaryRequestBody as exc: - context.headers.pop('Content-Type') - context.status_code = exc.status_code - return exc.response_text - return wrapped(*args, **kwargs) - - -ROUTES = set([]) + def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _ResponseType: + """Return a mock response.""" + ... # pylint: disable=unnecessary-ellipsis +@beartype def route( + *, path_pattern: str, - http_methods: Set[str], -) -> Callable[..., Callable]: - """ - Register a decorated method so that it can be recognized as a route. + http_methods: Iterable[HTTPMethod], +) -> Callable[[_RouteMethod[_P]], _RouteMethod[_P]]: + """Register a decorated method so that it can be recognized as a route. Args: path_pattern: The end part of a URL pattern. E.g. `/targets` or - `/targets/.+`. + `/targets/.+`. http_methods: HTTP methods that map to the route function. Returns: A decorator which takes methods and makes them recognizable as routes. """ - def decorator(method: Callable[..., str]) -> Callable[..., str]: - """ - Register a decorated method so that it can be recognized as a route. + @beartype + def decorator( + method: _RouteMethod[_P], + ) -> _RouteMethod[_P]: + """Register a decorated method so that it can be recognized as a + route. Returns: The given `method` with multiple changes, including added validators. """ - ROUTES.add( - Route( - route_name=method.__name__, - path_pattern=path_pattern, - http_methods=frozenset(http_methods), - ), + new_route = Route( + route_name=method.__name__, + path_pattern=path_pattern, + http_methods=frozenset(http_methods), ) - - decorators = [ - run_validators, - set_date_header, - set_content_length_header, - update_request_count, - ] - - for decorator in decorators: - # See https://github.com/PyCQA/pylint/issues/259 - method = decorator( # pylint: disable=no-value-for-parameter - method, - ) + _ROUTES.add(new_route) return method return decorator -def _get_target_from_request( - request_path: str, - databases: Set[VuforiaDatabase], -) -> Target: - """ - Given a request path with a target ID in the path, and a list of databases, - return the target with that ID from those databases. - """ - split_path = request_path.split('/') - target_id = split_path[-1] - all_database_targets = itertools.chain.from_iterable( - [database.targets for database in databases], - ) - [target] = [ - target - for target in all_database_targets - if target.target_id == target_id - ] - return target - - +@beartype(conf=BeartypeConf(is_pep484_tower=True)) class MockVuforiaWebServicesAPI: - """ - A fake implementation of the Vuforia Web Services API. - - This implementation is tied to the implementation of `requests_mock`. - """ + """A fake implementation of the Vuforia Web Services API.""" def __init__( self, - processing_time_seconds: Union[int, float], + *, + target_manager: TargetManager, + base_vws_url: str, + processing_time_seconds: float, + model_target_generation_failure: (ModelTargetGenerationFailure | None), + model_target_generation_warning: (ModelTargetGenerationWarning | None), + duplicate_match_checker: ImageMatcher, + target_tracking_rater: TargetTrackingRater, + vumark_generation_failure: VuMarkGenerationFailure | None, ) -> None: """ Args: + target_manager: Target Manager which stores databases. + base_vws_url: The base URL which the mock VWS API is served + from. + Generated reco counts reports are served from this URL. processing_time_seconds: The number of seconds to process each - image for. In the real Vuforia Web Services, this is not - deterministic. + image for. In the real Vuforia Web Services, this is not + deterministic. + model_target_generation_failure: A configured failure returned + after Model Target dataset processing completes. + model_target_generation_warning: A configured warning returned + after Model Target dataset processing completes. + duplicate_match_checker: A callable which takes two image + values + and returns whether they are duplicates. + target_tracking_rater: A callable for rating targets for + tracking. + vumark_generation_failure: A configured failure which takes + precedence over normal VuMark generation handling. Attributes: - databases: Target databases. routes: The `Route`s to be used in the mock. - request_count: The number of requests made to this API. """ - self.databases: Set[VuforiaDatabase] = set([]) - self.routes: Set[Route] = ROUTES + self._target_manager = target_manager + self._base_vws_url = base_vws_url + self.routes = _ROUTES self._processing_time_seconds = processing_time_seconds - self.request_count = 0 + self._model_target_generation_failure = model_target_generation_failure + self._model_target_generation_warning = model_target_generation_warning + self._duplicate_match_checker = duplicate_match_checker + self._target_tracking_rater = target_tracking_rater + self._vumark_generation_failure = vumark_generation_failure + + @route(path_pattern="/oauth2/token", http_methods={HTTPMethod.POST}) + def oauth2_token( # pylint: disable=no-self-use + self, + request: RequestData, + ) -> _ResponseType: + """Obtain an OAuth2 token for the Model Target Web API.""" + return oauth2_token(request=request) + + @route( + path_pattern="/modeltargets/datasets", + http_methods={HTTPMethod.POST}, + ) + def create_standard_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Create a standard Model Target dataset.""" + return create_model_target_dataset( + request=request, + dataset_store=self._target_manager, + processing_time_seconds=self._processing_time_seconds, + dataset_type=ModelTargetDatasetType.STANDARD, + generation_failure=self._model_target_generation_failure, + generation_warning=self._model_target_generation_warning, + ) + + @route( + path_pattern="/modeltargets/advancedDatasets", + http_methods={HTTPMethod.POST}, + ) + def create_advanced_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Create an advanced Model Target dataset.""" + return create_model_target_dataset( + request=request, + dataset_store=self._target_manager, + processing_time_seconds=self._processing_time_seconds, + dataset_type=ModelTargetDatasetType.ADVANCED, + generation_failure=self._model_target_generation_failure, + generation_warning=self._model_target_generation_warning, + ) + + @route( + path_pattern=( + "/modeltargets/datasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/status" + ), + http_methods={HTTPMethod.GET}, + ) + def get_standard_model_target_dataset_status( + self, + request: RequestData, + ) -> _ResponseType: + """Return a standard Model Target dataset creation status.""" + dataset_uuid = request.path.split(sep="/")[-2] + return get_model_target_dataset_status( + request=request, + dataset_store=self._target_manager, + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @route( + path_pattern=( + "/modeltargets/advancedDatasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/status" + ), + http_methods={HTTPMethod.GET}, + ) + def get_advanced_model_target_dataset_status( + self, + request: RequestData, + ) -> _ResponseType: + """Return an advanced Model Target dataset creation status.""" + dataset_uuid = request.path.split(sep="/")[-2] + return get_model_target_dataset_status( + request=request, + dataset_store=self._target_manager, + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + @route( + path_pattern=( + "/modeltargets/datasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/dataset" + ), + http_methods={HTTPMethod.GET}, + ) + def download_standard_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Download a standard Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-2] + return download_model_target_dataset( + request=request, + dataset_store=self._target_manager, + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @route( + path_pattern=( + "/modeltargets/advancedDatasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/dataset" + ), + http_methods={HTTPMethod.GET}, + ) + def download_advanced_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Download an advanced Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-2] + return download_model_target_dataset( + request=request, + dataset_store=self._target_manager, + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + @route( + path_pattern=( + f"/modeltargets/datasets/{_MODEL_TARGET_DATASET_UUID_PATTERN}" + ), + http_methods={HTTPMethod.DELETE}, + ) + def delete_standard_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Delete a standard Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-1] + return delete_model_target_dataset( + request=request, + dataset_store=self._target_manager, + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @route( + path_pattern=( + "/modeltargets/advancedDatasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}" + ), + http_methods={HTTPMethod.DELETE}, + ) + def delete_advanced_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Delete an advanced Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-1] + return delete_model_target_dataset( + request=request, + dataset_store=self._target_manager, + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ) @route( - path_pattern='/targets', - http_methods={POST}, + path_pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + http_methods={HTTPMethod.POST}, ) - def add_target( + def reco_counts_report(self, request: RequestData) -> _ResponseType: + """Request a reco counts report for a database. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api + """ + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + return create_reco_counts_report( + request_body=request.body, + report_store=self._target_manager, + generation_time_seconds=self._processing_time_seconds, + base_url=self._base_vws_url.rstrip("/"), + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + + @route( + path_pattern=RECO_COUNTS_DOWNLOAD_PATH_PATTERN, + http_methods={HTTPMethod.GET}, + ) + def download_reco_counts_report( self, - request: _RequestObjectProxy, - context: _Context, - ) -> str: + request: RequestData, + ) -> _ResponseType: + """Download a generated reco counts report. + + This stands in for the presigned URL which real Vuforia returns, so + it does not require any authorization. """ - Add a target. + report_id = request.path.split(sep="/")[-1] + return download_reco_counts_report( + report_store=self._target_manager, + report_id=report_id, + ) + + @route( + path_pattern="/targets", + http_methods={HTTPMethod.POST}, + ) + def add_target(self, request: RequestData) -> _ResponseType: + """Add a target. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Add-a-Target + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add """ - name = request.json()['name'] + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + database = get_database_matching_server_keys( request_headers=request.headers, request_body=request.body, request_method=request.method, request_path=request.path, - databases=self.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) + request_json: dict[str, Any] = json.loads(s=request.body) + given_active_flag = request_json.get("active_flag") + active_flag = { + None: True, + True: True, + False: False, + }[given_active_flag] - targets = ( - target for target in database.targets if not target.delete_date - ) - if any(target.name == name for target in targets): - context.status_code = HTTPStatus.FORBIDDEN - body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.TARGET_NAME_EXIST.value, - } - return json_dump(body) - - active_flag = request.json().get('active_flag') - if active_flag is None: - active_flag = True - - image = request.json()['image'] - decoded = base64.b64decode(image) - image_file = io.BytesIO(decoded) - - new_target = Target( - name=request.json()['name'], - width=request.json()['width'], - image=image_file, + application_metadata = request_json.get("application_metadata") + + new_target = ImageTarget( + name=request_json["name"], + width=request_json["width"], + image_value=base64.b64decode(s=request_json["image"]), active_flag=active_flag, processing_time_seconds=self._processing_time_seconds, - application_metadata=request.json().get('application_metadata'), + application_metadata=application_metadata, + target_tracking_rater=self._target_tracking_rater, ) database.targets.add(new_target) - context.status_code = HTTPStatus.CREATED + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + status_code = HTTPStatus.CREATED body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.TARGET_CREATED.value, - 'target_id': new_target.target_id, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_CREATED.value, + "target_id": new_target.target_id, + } + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "Content-Length": str(object=len(body_json)), + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - return json_dump(body) + return status_code, headers, body_json @route( - path_pattern=f'/targets/{_TARGET_ID_PATTERN}', - http_methods={DELETE}, + path_pattern=f"/targets/{_TARGET_ID_PATTERN}", + http_methods={HTTPMethod.DELETE}, ) - def delete_target( - self, - request: _RequestObjectProxy, - context: _Context, - ) -> str: - """ - Delete a target. + def delete_target(self, request: RequestData) -> _ResponseType: + """Delete a target. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Delete-a-Target + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete """ - body: Dict[str, str] = {} - target = _get_target_from_request( + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + + database = get_database_matching_server_keys( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, request_path=request.path, - databases=self.databases, + databases=self._target_manager.cloud_databases, ) + target_id = request.path.split(sep="/")[-1] + target = database.get_target(target_id=target_id) + if target.status == TargetStatuses.PROCESSING.value: - context.status_code = HTTPStatus.FORBIDDEN - body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.TARGET_STATUS_PROCESSING.value, - } - return json_dump(body) + target_processing_exception = TargetStatusProcessingError() + return ( + target_processing_exception.status_code, + target_processing_exception.headers, + target_processing_exception.response_text, + ) - target.delete() + now = datetime.datetime.now(tz=target.upload_date.tzinfo) + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + delete_date=now, + ) + database.targets.remove(target) + database.targets.add(new_target) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + } + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - return json_dump(body) + return HTTPStatus.OK, headers, body_json - @route(path_pattern='/summary', http_methods={GET}) - def database_summary( - self, - request: _RequestObjectProxy, - context: _Context, # pylint: disable=unused-argument - ) -> str: - """ - Get a database summary report. + @route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}/instances", + http_methods={HTTPMethod.POST}, + ) + def generate_vumark_instance(self, request: RequestData) -> _ResponseType: + """Generate a VuMark instance.""" + if self._vumark_generation_failure is not None: + body_json = json_dump( + body={ + "transaction_id": uuid.uuid4().hex, + "result_code": self._vumark_generation_failure.value, + } + ) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + return ( + self._vumark_generation_failure.status_code, + { + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + "Date": date, + }, + body_json, + ) + + valid_accept_types: dict[str, bytes] = { + "image/png": VUMARK_PNG, + "image/svg+xml": VUMARK_SVG, + "application/pdf": VUMARK_PDF, + } + try: + all_databases: list[CloudDatabase | VuMarkDatabase] = [ + *self._target_manager.cloud_databases, + *self._target_manager.vumark_databases, + ] + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=all_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + + database = get_database_matching_server_keys( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=all_databases, + ) + if not isinstance(database, VuMarkDatabase): + raise InvalidTargetTypeError + + target_id = request.path.split(sep="/")[-2] + target = database.get_vumark_target(target_id=target_id) + if target.status != TargetStatuses.SUCCESS.value: + raise TargetStatusNotSuccessError + + accept = dict(request.headers).get("Accept", "") + if accept not in valid_accept_types: + raise InvalidAcceptHeaderError + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + + response_body = valid_accept_types[accept] + content_type = accept + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + headers = { + "Connection": "keep-alive", + "Content-Type": content_type, + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return HTTPStatus.OK, headers, response_body + + @route(path_pattern="/summary", http_methods={HTTPMethod.GET}) + def database_summary(self, request: RequestData) -> _ResponseType: + """Get a database summary report. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Get-a-Database-Summary-Report + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report """ - body: Dict[str, Union[str, int]] = {} + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text database = get_database_matching_server_keys( request_headers=request.headers, request_body=request.body, request_method=request.method, request_path=request.path, - databases=self.databases, - ) - - assert isinstance(database, VuforiaDatabase) - active_images = len( - [ - target - for target in database.targets - if target.status == TargetStatuses.SUCCESS.value - and target.active_flag - and not target.delete_date - ], + databases=self._target_manager.cloud_databases, ) - failed_images = len( - [ - target - for target in database.targets - if target.status == TargetStatuses.FAILED.value - and not target.delete_date - ], + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, ) - - inactive_images = len( - [ - target - for target in database.targets - if target.status == TargetStatuses.SUCCESS.value - and not target.active_flag - and not target.delete_date - ], - ) - - processing_images = len( - [ - target - for target in database.targets - if target.status == TargetStatuses.PROCESSING.value - and not target.delete_date - ], - ) - body = { - 'result_code': ResultCodes.SUCCESS.value, - 'transaction_id': uuid.uuid4().hex, - 'name': database.database_name, - 'active_images': active_images, - 'inactive_images': inactive_images, - 'failed_images': failed_images, - 'target_quota': 1000, - 'total_recos': 0, - 'current_month_recos': 0, - 'previous_month_recos': 0, - 'processing_images': processing_images, - 'reco_threshold': 1000, - 'request_quota': 100000, - # We have ``self.request_count`` but Vuforia always shows 0. - # This was not always the case. - 'request_usage': 0, + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "name": database.database_name, + "active_images": len(database.active_targets), + "inactive_images": len(database.inactive_targets), + "failed_images": len(database.failed_targets), + "target_quota": database.target_quota, + "total_recos": database.total_recos, + "current_month_recos": database.current_month_recos, + "previous_month_recos": database.previous_month_recos, + "processing_images": len(database.processing_targets), + "reco_threshold": database.reco_threshold, + "request_quota": database.request_quota, + "request_usage": 0, + } + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - return json_dump(body) + return HTTPStatus.OK, headers, body_json - @route(path_pattern='/targets', http_methods={GET}) - def target_list( - self, - request: _RequestObjectProxy, - context: _Context, # pylint: disable=unused-argument - ) -> str: - """ - Get a list of all targets. + @route(path_pattern="/targets", http_methods={HTTPMethod.GET}) + def target_list(self, request: RequestData) -> _ResponseType: + """Get a list of all targets. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Get-a-Target-List-for-a-Cloud-Database + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list """ + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + database = get_database_matching_server_keys( request_headers=request.headers, request_body=request.body, request_method=request.method, request_path=request.path, - databases=self.databases, + databases=self._target_manager.cloud_databases, + ) + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, ) - assert isinstance(database, VuforiaDatabase) - results = [ + response_results = [ target.target_id - for target in database.targets - if not target.delete_date + for target in sorted_targets(targets=database.not_deleted_targets) ] - - body: Dict[str, Union[str, List[str]]] = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.SUCCESS.value, - 'results': results, + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "results": response_results, + } + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - return json_dump(body) + return HTTPStatus.OK, headers, body_json - @route(path_pattern=f'/targets/{_TARGET_ID_PATTERN}', http_methods={GET}) - def get_target( - self, - request: _RequestObjectProxy, - context: _Context, # pylint: disable=unused-argument - ) -> str: - """ - Get details of a target. + @route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}", + http_methods={HTTPMethod.GET}, + ) + def get_target(self, request: RequestData) -> _ResponseType: + """Get details of a target. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Retrieve-a-Target-Record + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record """ - target = _get_target_from_request( + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + + database = get_database_matching_server_keys( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, request_path=request.path, - databases=self.databases, + databases=self._target_manager.cloud_databases, ) + target_id = request.path.split(sep="/")[-1] + target = database.get_target(target_id=target_id) + width = target.width + tracking_rating = target.tracking_rating + reco_rating = target.reco_rating target_record = { - 'target_id': target.target_id, - 'active_flag': target.active_flag, - 'name': target.name, - 'width': target.width, - 'tracking_rating': target.tracking_rating, - 'reco_rating': target.reco_rating, + "target_id": target.target_id, + "active_flag": target.active_flag, + "name": target.name, + "width": width, + "tracking_rating": tracking_rating, + "reco_rating": reco_rating, } + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) body = { - 'result_code': ResultCodes.SUCCESS.value, - 'transaction_id': uuid.uuid4().hex, - 'target_record': target_record, - 'status': target.status, + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "target_record": target_record, + "status": target.status, + } + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - return json_dump(body) + return HTTPStatus.OK, headers, body_json @route( - path_pattern=f'/duplicates/{_TARGET_ID_PATTERN}', - http_methods={GET}, + path_pattern=f"/duplicates/{_TARGET_ID_PATTERN}", + http_methods={HTTPMethod.GET}, ) - def get_duplicates( - self, - request: _RequestObjectProxy, - context: _Context, # pylint: disable=unused-argument - ) -> str: - """ - Get targets which may be considered duplicates of a given target. + def get_duplicates(self, request: RequestData) -> _ResponseType: + """Get targets which may be considered duplicates of a given + target. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Check-for-Duplicate-Targets + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check """ - target = _get_target_from_request( - request_path=request.path, - databases=self.databases, - ) + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + database = get_database_matching_server_keys( request_headers=request.headers, request_body=request.body, request_method=request.method, request_path=request.path, - databases=self.databases, + databases=self._target_manager.cloud_databases, ) + target_id = request.path.split(sep="/")[-1] + target = database.get_target(target_id=target_id) - assert isinstance(database, VuforiaDatabase) - other_targets = set(database.targets) - set([target]) + other_targets = sorted_targets(targets=database.targets - {target}) - similar_targets: List[str] = [ + similar_targets = [ other.target_id for other in other_targets - if Image.open(other.image) == Image.open(target.image) + if self._duplicate_match_checker( + first_image_content=target.image_value, + second_image_content=other.image_value, + ) and TargetStatuses.FAILED.value - not in (target.status, other.status) + not in {target.status, other.status} and TargetStatuses.PROCESSING.value != other.status and other.active_flag ] + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.SUCCESS.value, - 'similar_targets': similar_targets, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "similar_targets": similar_targets, + } + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - return json_dump(body) + return HTTPStatus.OK, headers, body_json @route( - path_pattern=f'/targets/{_TARGET_ID_PATTERN}', - http_methods={PUT}, + path_pattern=f"/targets/{_TARGET_ID_PATTERN}", + http_methods={HTTPMethod.PUT}, ) - def update_target( - self, - request: _RequestObjectProxy, - context: _Context, - ) -> str: - """ - Update a target. + def update_target(self, request: RequestData) -> _ResponseType: + """Update a target. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Update-a-Target + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update """ - target = _get_target_from_request( - request_path=request.path, - databases=self.databases, - ) - body: Dict[str, str] = {} + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + database = get_database_matching_server_keys( request_headers=request.headers, request_body=request.body, request_method=request.method, request_path=request.path, - databases=self.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) + target_id = request.path.split(sep="/")[-1] + target = database.get_target(target_id=target_id) + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) if target.status != TargetStatuses.SUCCESS.value: - context.status_code = HTTPStatus.FORBIDDEN - body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.TARGET_STATUS_NOT_SUCCESS.value, - } - return json_dump(body) - - if 'width' in request.json(): - target.width = request.json()['width'] - - if 'active_flag' in request.json(): - active_flag = request.json()['active_flag'] - if active_flag is None: - body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.FAIL.value, - } - context.status_code = HTTPStatus.BAD_REQUEST - return json_dump(body) - target.active_flag = active_flag - - if 'application_metadata' in request.json(): - if request.json()['application_metadata'] is None: - body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.FAIL.value, - } - context.status_code = HTTPStatus.BAD_REQUEST - return json_dump(body) - application_metadata = request.json()['application_metadata'] - target.application_metadata = application_metadata - - if 'name' in request.json(): - name = request.json()['name'] - other_targets = set(database.targets) - set([target]) - if any( - other.name == name - for other in other_targets - if not other.delete_date - ): - context.status_code = HTTPStatus.FORBIDDEN - body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.TARGET_NAME_EXIST.value, - } - return json_dump(body) - target.name = name + exception = TargetStatusNotSuccessError() + return ( + exception.status_code, + exception.headers, + exception.response_text, + ) + + request_json: dict[str, Any] = json.loads(s=request.body) + name = request_json.get("name", target.name) + active_flag = request_json.get("active_flag", target.active_flag) + + if "active_flag" in request_json and active_flag is None: + fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) + return ( + fail_exception.status_code, + fail_exception.headers, + fail_exception.response_text, + ) + + gmt = ZoneInfo(key="GMT") + last_modified_date = datetime.datetime.now(tz=gmt) - if 'image' in request.json(): - image = request.json()['image'] - decoded = base64.b64decode(image) - image_file = io.BytesIO(decoded) - target.image = image_file + width = request_json.get("width", target.width) + application_metadata = request_json.get( + "application_metadata", + target.application_metadata, + ) + + image_value = target.image_value + if "image" in request_json: + image_value = base64.b64decode(s=request_json["image"]) + + if ( + "application_metadata" in request_json + and application_metadata is None + ): + fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) + return ( + fail_exception.status_code, + fail_exception.headers, + fail_exception.response_text, + ) - # In the real implementation, the tracking rating can stay the same. - # However, for demonstration purposes, the tracking rating changes but - # when the target is updated. - available_values = list(set(range(6)) - set([target.tracking_rating])) - target.processed_tracking_rating = random.choice(available_values) + # See https://github.com/facebook/pyrefly/issues/1897 + new_target: ImageTarget = copy.replace( + target, # pyrefly: ignore[bad-argument-type] + name=name, + width=width, + active_flag=active_flag, + application_metadata=application_metadata, + image_value=image_value, + last_modified_date=last_modified_date, + ) - gmt = ZoneInfo('GMT') - now = datetime.datetime.now(tz=gmt) - target.last_modified_date = now + database.targets.remove(target) + database.targets.add(new_target) body = { - 'result_code': ResultCodes.SUCCESS.value, - 'transaction_id': uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, } - return json_dump(body) + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "Content-Length": str(object=len(body_json)), + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return HTTPStatus.OK, headers, body_json - @route(path_pattern=f'/summary/{_TARGET_ID_PATTERN}', http_methods={GET}) - def target_summary( - self, - request: _RequestObjectProxy, - context: _Context, # pylint: disable=unused-argument - ) -> str: - """ - Get a summary report for a target. + @route( + path_pattern=f"/summary/{_TARGET_ID_PATTERN}", + http_methods={HTTPMethod.GET}, + ) + def target_summary(self, request: RequestData) -> _ResponseType: + """Get a summary report for a target. Fake implementation of - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Retrieve-a-Target-Summary-Report + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#retrieve-report """ - target = _get_target_from_request( - request_path=request.path, - databases=self.databases, - ) + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + database = get_database_matching_server_keys( request_headers=request.headers, request_body=request.body, request_method=request.method, request_path=request.path, - databases=self.databases, + databases=self._target_manager.cloud_databases, ) + target_id = request.path.split(sep="/")[-1] + target = database.get_target(target_id=target_id) - assert isinstance(database, VuforiaDatabase) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + tracking_rating = target.tracking_rating + total_recos = target.total_recos + current_month_recos = target.current_month_recos + previous_month_recos = target.previous_month_recos body = { - 'status': target.status, - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.SUCCESS.value, - 'database_name': database.database_name, - 'target_name': target.name, - 'upload_date': target.upload_date.strftime('%Y-%m-%d'), - 'active_flag': target.active_flag, - 'tracking_rating': target.tracking_rating, - 'total_recos': 0, - 'current_month_recos': 0, - 'previous_month_recos': 0, + "status": target.status, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "database_name": database.database_name, + "target_name": target.name, + "upload_date": target.upload_date.strftime(format="%Y-%m-%d"), + "active_flag": target.active_flag, + "tracking_rating": tracking_rating, + "total_recos": total_recos, + "current_month_recos": current_month_recos, + "previous_month_recos": previous_month_recos, + } + body_json = json_dump(body=body) + headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - return json_dump(body) + + return HTTPStatus.OK, headers, body_json diff --git a/src/mock_vws/_respx_mock_server/__init__.py b/src/mock_vws/_respx_mock_server/__init__.py new file mode 100644 index 000000000..a5ffb7cab --- /dev/null +++ b/src/mock_vws/_respx_mock_server/__init__.py @@ -0,0 +1 @@ +"""A fake implementation of Vuforia Web Services for use with respx.""" diff --git a/src/mock_vws/_respx_mock_server/decorators.py b/src/mock_vws/_respx_mock_server/decorators.py new file mode 100644 index 000000000..c9a59e6c8 --- /dev/null +++ b/src/mock_vws/_respx_mock_server/decorators.py @@ -0,0 +1,186 @@ +"""Helpers for mocking Vuforia with httpx via respx.""" + +import re +from collections.abc import Callable, Mapping +from typing import Protocol +from urllib.parse import urlparse + +import httpx +import respx + +from mock_vws._mock_common import RequestData, Route + +_ResponseType = tuple[int, Mapping[str, str], str | bytes] + + +class _APIHandler(Protocol): + """An API handler with mock routes.""" + + routes: set[Route] + + +def _to_request_data( + request: httpx.Request, + *, + base_path: str, +) -> RequestData: + """Convert an httpx.Request to a RequestData. + + Args: + request: The httpx request to convert. + base_path: The base path prefix to strip from the request path. + + Returns: + A RequestData with method, path, headers, and body set. + """ + path = request.url.raw_path.decode(encoding="ascii") + if base_path and path.startswith(base_path): + path = path[len(base_path) :] + return RequestData( + method=request.method, + path=path, + headers={k.title(): v for k, v in request.headers.items()}, + body=request.content, + ) + + +def _block_unmatched(request: httpx.Request) -> httpx.Response: + """Raise ConnectError for unmatched requests when real_http=False. + + Args: + request: The unmatched httpx request. + + Raises: + Exception: A connection error is always raised to block + unmatched requests. + """ + raise httpx.ConnectError( + message="Connection refused by mock", + request=request, + ) + + +def _make_respx_callback( + *, + handler: Callable[[RequestData], _ResponseType], + base_path: str, + delay_seconds: float, + sleep_fn: Callable[[float], None], +) -> Callable[[httpx.Request], httpx.Response]: + """Create a respx-compatible callback from a handler. + + Args: + handler: A handler that takes a RequestData and returns a + response tuple. + base_path: The base path prefix to strip from the request path. + delay_seconds: The number of seconds to delay the response by. + sleep_fn: The function to use for sleeping during delays. + + Returns: + A callback that takes an httpx.Request and returns an + httpx.Response. + """ + + def callback(request: httpx.Request) -> httpx.Response: + """Handle an httpx request by converting it and calling the + handler. + + Args: + request: The httpx request to handle. + + Returns: + An httpx.Response built from the handler's return value. + + Raises: + Exception: A timeout error is raised when the response + delay exceeds the read timeout. + """ + request_data = _to_request_data( + request=request, + base_path=base_path, + ) + timeout_info: dict[str, float | None] = request.extensions.get( + "timeout", {} + ) + read_timeout = timeout_info.get("read") + if read_timeout is not None and delay_seconds > read_timeout: + sleep_fn(read_timeout) + raise httpx.ReadTimeout( + message="Response delay exceeded read timeout", + request=request, + ) + status_code, headers, body = handler(request_data) + sleep_fn(delay_seconds) + if isinstance(body, str): + body = body.encode() + return httpx.Response( + status_code=status_code, + headers=headers, + content=body, + ) + + return callback + + +def start_respx_router( + *, + mock_vws_api: _APIHandler, + mock_vwq_api: _APIHandler, + base_vws_url: str, + base_vwq_url: str, + response_delay_seconds: float, + sleep_fn: Callable[[float], None], + real_http: bool, +) -> respx.MockRouter: + """Configure and start a respx router with Vuforia routes. + + Args: + mock_vws_api: The VWS API handler. + mock_vwq_api: The VWQ API handler. + base_vws_url: The base URL for the VWS API. + base_vwq_url: The base URL for the VWQ API. + response_delay_seconds: The number of seconds to delay responses. + sleep_fn: The function to use for sleeping during delays. + real_http: Whether to pass through unmatched requests. + + Returns: + A started respx router. + """ + router = respx.MockRouter( + assert_all_called=False, + assert_all_mocked=False, + ) + + for api, base_url in ( + (mock_vws_api, base_vws_url), + (mock_vwq_api, base_vwq_url), + ): + base_path = urlparse(url=base_url).path.rstrip("/") + for route in api.routes: + url_pattern = base_url.rstrip("/") + route.path_pattern + "$" + compiled_url_pattern = re.compile(pattern=url_pattern) + + for http_method in route.http_methods: + original_callback = getattr( # pylint: disable=bad-builtin + api, + route.route_name, + ) + router.route( + method=http_method, + url=compiled_url_pattern, + ).mock( + side_effect=_make_respx_callback( + handler=original_callback, + base_path=base_path, + delay_seconds=response_delay_seconds, + sleep_fn=sleep_fn, + ), + ) + + if real_http: + router.route().pass_through() + else: + router.route().mock(side_effect=_block_unmatched) + + router.start() + return router diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index b038f7b1f..5717b3c20 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -1,10 +1,10 @@ -""" -Input validators to use in the mock. -""" +"""Input validators to use in the mock.""" -from typing import Dict, Set +from collections.abc import Iterable, Mapping -from mock_vws.database import VuforiaDatabase +from beartype import beartype + +from mock_vws._database_matchers import AnyDatabase from .active_flag_validators import validate_active_flag from .auth_validators import ( @@ -19,6 +19,7 @@ validate_content_length_header_not_too_small, ) from .content_type_validators import validate_content_type_header_given +from .database_id_validators import validate_database_id_matches_keys from .date_validators import ( validate_date_format, validate_date_header_given, @@ -29,10 +30,16 @@ validate_image_data_type, validate_image_encoding, validate_image_format, + validate_image_integrity, validate_image_is_image, + validate_image_pixel_count, validate_image_size, ) -from .json_validators import validate_json +from .instance_id_validators import ( + validate_instance_id_not_empty, + validate_instance_id_type, +) +from .json_validators import validate_body_given, validate_json from .key_validators import validate_keys from .metadata_validators import ( validate_metadata_encoding, @@ -41,23 +48,33 @@ ) from .name_validators import ( validate_name_characters_in_range, + validate_name_does_not_exist_existing_target, + validate_name_does_not_exist_new_target, validate_name_length, validate_name_type, ) from .project_state_validators import validate_project_state +from .request_quota_validators import validate_request_quota +from .request_rate_validators import ( + RequestRateLimiter, + validate_request_rate, +) +from .target_quota_validators import validate_target_quota from .target_validators import validate_target_id_exists from .width_validators import validate_width +@beartype def run_services_validators( + *, request_path: str, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Set[VuforiaDatabase], + databases: Iterable[AnyDatabase], + request_rate_limiter: RequestRateLimiter, ) -> None: - """ - Run all validators. + """Run all validators. Args: request_path: The path of the request. @@ -65,6 +82,7 @@ def run_services_validators( request_body: The body of the request. request_method: The HTTP method of the request. databases: All Vuforia databases. + request_rate_limiter: The rate limiter tracking recent requests. """ validate_auth_header_exists(request_headers=request_headers) validate_auth_header_has_signature(request_headers=request_headers) @@ -79,6 +97,28 @@ def run_services_validators( request_path=request_path, databases=databases, ) + validate_database_id_matches_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + validate_request_quota( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + validate_request_rate( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + request_rate_limiter=request_rate_limiter, + ) validate_project_state( request_headers=request_headers, request_body=request_body, @@ -86,6 +126,13 @@ def run_services_validators( request_path=request_path, databases=databases, ) + validate_target_quota( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) validate_target_id_exists( request_headers=request_headers, request_body=request_body, @@ -93,10 +140,18 @@ def run_services_validators( request_path=request_path, databases=databases, ) - validate_json( + + validate_body_given( request_body=request_body, request_method=request_method, ) + + validate_date_header_given(request_headers=request_headers) + validate_date_format(request_headers=request_headers) + validate_date_in_range(request_headers=request_headers) + + validate_json(request_body=request_body, request_path=request_path) + validate_keys( request_body=request_body, request_path=request_path, @@ -106,6 +161,8 @@ def run_services_validators( validate_metadata_encoding(request_body=request_body) validate_metadata_size(request_body=request_body) validate_active_flag(request_body=request_body) + validate_instance_id_type(request_body=request_body) + validate_instance_id_not_empty(request_body=request_body) validate_image_data_type(request_body=request_body) validate_image_encoding(request_body=request_body) @@ -113,6 +170,8 @@ def run_services_validators( validate_image_format(request_body=request_body) validate_image_color_space(request_body=request_body) validate_image_size(request_body=request_body) + validate_image_pixel_count(request_body=request_body) + validate_image_integrity(request_body=request_body) validate_name_type(request_body=request_body) validate_name_length(request_body=request_body) @@ -121,6 +180,20 @@ def run_services_validators( request_method=request_method, request_path=request_path, ) + validate_name_does_not_exist_new_target( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + validate_name_does_not_exist_existing_target( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) validate_width(request_body=request_body) validate_content_type_header_given( @@ -128,11 +201,6 @@ def run_services_validators( request_method=request_method, ) - validate_date_header_given(request_headers=request_headers) - - validate_date_format(request_headers=request_headers) - validate_date_in_range(request_headers=request_headers) - validate_content_length_header_is_int( request_headers=request_headers, request_body=request_body, diff --git a/src/mock_vws/_services_validators/active_flag_validators.py b/src/mock_vws/_services_validators/active_flag_validators.py index b1d54067b..66a446945 100644 --- a/src/mock_vws/_services_validators/active_flag_validators.py +++ b/src/mock_vws/_services_validators/active_flag_validators.py @@ -1,35 +1,43 @@ -""" -Validators for the active flag. -""" +"""Validators for the active flag.""" import json +import logging from http import HTTPStatus -from mock_vws._services_validators.exceptions import Fail +from beartype import beartype +from mock_vws._services_validators.exceptions import FailError + +_LOGGER = logging.getLogger(name=__name__) -def validate_active_flag(request_body: bytes) -> None: - """ - Validate the active flag data given to the endpoint. + +@beartype +def validate_active_flag(*, request_body: bytes) -> None: + """Validate the active flag data given to the endpoint. Args: request_body: The body of the request. Raises: - Fail: There is active flag data given to the endpoint which is not + FailError: There is active flag data given to the endpoint which is not either a Boolean or NULL. """ - if not request_body: return request_text = request_body.decode() - if 'active_flag' not in json.loads(request_text): + if "active_flag" not in json.loads(s=request_text): return - active_flag = json.loads(request_text).get('active_flag') + active_flag = json.loads(s=request_text).get("active_flag") - if active_flag is None or isinstance(active_flag, bool): + if active_flag in {True, False, None}: return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning( + msg=( + 'The value of "active_flag" is not a Boolean or NULL. ' + "This is not allowed." + ), + ) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) diff --git a/src/mock_vws/_services_validators/auth_validators.py b/src/mock_vws/_services_validators/auth_validators.py index ed1b3dac6..1117b4636 100644 --- a/src/mock_vws/_services_validators/auth_validators.py +++ b/src/mock_vws/_services_validators/auth_validators.py @@ -1,84 +1,102 @@ -""" -Authorization header validators to use in the mock. -""" +"""Authorization header validators to use in the mock.""" +import logging +from collections.abc import Iterable, Mapping from http import HTTPStatus -from typing import Dict, Set -from mock_vws._database_matchers import get_database_matching_server_keys +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) from mock_vws._services_validators.exceptions import ( - AuthenticationFailure, - Fail, + AuthenticationFailureError, + FailError, ) -from mock_vws.database import VuforiaDatabase +_LOGGER = logging.getLogger(name=__name__) -def validate_auth_header_exists(request_headers: Dict[str, str]) -> None: - """ - Validate that there is an authorization header given to a VWS endpoint. + +@beartype +def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: + """Validate that there is an authorization header given to a VWS + endpoint. Args: request_headers: The headers sent with the request. Raises: - AuthenticationFailure: There is no "Authorization" header. + AuthenticationFailureError: There is no "Authorization" header. """ - if 'Authorization' not in request_headers: - raise AuthenticationFailure + if "Authorization" not in request_headers: + _LOGGER.warning(msg="There is no authorization header.") + raise AuthenticationFailureError +@beartype def validate_access_key_exists( - request_headers: Dict[str, str], - databases: Set[VuforiaDatabase], + *, + request_headers: Mapping[str, str], + databases: Iterable[AnyDatabase], ) -> None: - """ - Validate the authorization header includes an access key for a database. + """Validate the authorization header includes an access key for a + database. Args: request_headers: The headers sent with the request. databases: All Vuforia databases. Raises: - Fail: The access key does not match a given database. + FailError: The access key does not match a given database. """ - header = request_headers['Authorization'] - first_part, _ = header.split(':') - _, access_key = first_part.split(' ') + header = request_headers["Authorization"] + first_part, _ = header.split(sep=":") + _, access_key = first_part.split(sep=" ") for database in databases: if access_key == database.server_access_key: return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning( + 'The access key "%s" does not match a known database.', + access_key, + ) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) +@beartype def validate_auth_header_has_signature( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], ) -> None: - """ - Validate the authorization header includes a signature. + """Validate the authorization header includes a signature. Args: request_headers: The headers sent with the request. Raises: - Fail: The "Authorization" header does not include a signature. + FailError: The "Authorization" header does not include a signature. """ - header = request_headers['Authorization'] - if header.count(':') == 1 and header.split(':')[1]: + header = request_headers["Authorization"] + if header.count(":") == 1 and header.split(sep=":")[1]: return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning( + msg="The authorization header does not include a signature.", + ) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) +@beartype def validate_authorization( + *, request_path: str, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Set[VuforiaDatabase], + databases: Iterable[AnyDatabase], ) -> None: - """ - Validate the authorization header given to a VWS endpoint. + """Validate the authorization header given to a VWS endpoint. Args: request_path: The path of the request. @@ -88,16 +106,19 @@ def validate_authorization( databases: All Vuforia databases. Raises: - AuthenticationFailure: No database matches the given authorization + AuthenticationFailureError: No database matches the given authorization header. """ - database = get_database_matching_server_keys( - request_headers=request_headers, - request_body=request_body, - request_method=request_method, - request_path=request_path, - databases=databases, - ) - - if database is None: - raise AuthenticationFailure + try: + get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + except ValueError as exc: + _LOGGER.warning( + msg="No database matches the given authorization header.", + ) + raise AuthenticationFailureError from exc diff --git a/src/mock_vws/_services_validators/content_length_validators.py b/src/mock_vws/_services_validators/content_length_validators.py index 87427b204..eaa41b2af 100644 --- a/src/mock_vws/_services_validators/content_length_validators.py +++ b/src/mock_vws/_services_validators/content_length_validators.py @@ -1,80 +1,102 @@ -""" -Content-Length header validators to use in the mock. -""" +"""Content-Length header validators to use in the mock.""" -from typing import Dict +import logging +from collections.abc import Mapping + +from beartype import beartype from mock_vws._services_validators.exceptions import ( - AuthenticationFailure, - ContentLengthHeaderNotInt, - ContentLengthHeaderTooLarge, + AuthenticationFailureError, + ContentLengthHeaderNotIntError, + ContentLengthHeaderTooLargeError, ) +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_content_length_header_is_int( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``Content-Length`` header is an integer. + """Validate the ``Content-Length`` header is an integer. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - ContentLengthHeaderNotInt: The content length header is not an + ContentLengthHeaderNotIntError: The content length header is not an integer """ - body_length = len(request_body if request_body else b'') - given_content_length = request_headers.get('Content-Length', body_length) + body_length = len(request_body) + request_headers_dict = dict(request_headers) + given_content_length = request_headers_dict.get( + "Content-Length", + body_length, + ) try: int(given_content_length) except ValueError as exc: - raise ContentLengthHeaderNotInt from exc + _LOGGER.warning(msg="The Content-Length header is not an integer.") + raise ContentLengthHeaderNotIntError from exc +@beartype def validate_content_length_header_not_too_large( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``Content-Length`` header is not too large. + """Validate the ``Content-Length`` header is not too large. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - ContentLengthHeaderTooLarge: The given content length header says + ContentLengthHeaderTooLargeError: The given content length header says that the content length is greater than the body length. """ - body_length = len(request_body if request_body else b'') - given_content_length = request_headers.get('Content-Length', body_length) + body_length = len(request_body) + request_headers_dict = dict(request_headers) + given_content_length = request_headers_dict.get( + "Content-Length", + body_length, + ) given_content_length_value = int(given_content_length) - if given_content_length_value > body_length: - raise ContentLengthHeaderTooLarge + # We skip coverage here as running a test to cover this is very slow. + if given_content_length_value > body_length: # pragma: no cover + _LOGGER.warning(msg="The Content-Length header is too large.") + raise ContentLengthHeaderTooLargeError +@beartype def validate_content_length_header_not_too_small( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, ) -> None: - """ - Validate the ``Content-Length`` header is not too small. + """Validate the ``Content-Length`` header is not too small. Args: request_headers: The headers sent with the request. request_body: The body of the request. Raises: - AuthenticationFailure: The given content length header says that + AuthenticationFailureError: The given content length header says that the content length is smaller than the body length. """ - body_length = len(request_body if request_body else b'') - given_content_length = request_headers.get('Content-Length', body_length) + body_length = len(request_body) + request_headers_dict = dict(request_headers) + given_content_length = request_headers_dict.get( + "Content-Length", + body_length, + ) given_content_length_value = int(given_content_length) if given_content_length_value < body_length: - raise AuthenticationFailure + _LOGGER.warning(msg="The Content-Length header is too small.") + raise AuthenticationFailureError diff --git a/src/mock_vws/_services_validators/content_type_validators.py b/src/mock_vws/_services_validators/content_type_validators.py index 21a90e4ba..12913fa6f 100644 --- a/src/mock_vws/_services_validators/content_type_validators.py +++ b/src/mock_vws/_services_validators/content_type_validators.py @@ -1,31 +1,42 @@ -""" -Content-Type header validators to use in the mock. -""" +"""Content-Type header validators to use in the mock.""" -from typing import Dict +import logging +from collections.abc import Mapping +from http import HTTPMethod -from requests_mock import POST, PUT +from beartype import beartype -from mock_vws._services_validators.exceptions import AuthenticationFailure +from mock_vws._services_validators.exceptions import AuthenticationFailureError +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_content_type_header_given( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_method: str, ) -> None: - """ - Validate that there is a non-empty content type header given if required. + """Validate that there is a non-empty content type header given if + required. Args: request_headers: The headers sent with the request. request_method: The HTTP method of the request. Raises: - AuthenticationFailure: No ``Content-Type`` header is given and the + AuthenticationFailureError: No ``Content-Type`` header is given and the request requires one. """ - request_needs_content_type = bool(request_method in (POST, PUT)) - if request_headers.get('Content-Type') or not request_needs_content_type: + request_headers_dict = dict(request_headers) + request_needs_content_type = bool( + request_method in {HTTPMethod.POST, HTTPMethod.PUT}, + ) + if ( + request_headers_dict.get("Content-Type") + or not request_needs_content_type + ): return - raise AuthenticationFailure + _LOGGER.warning(msg="No Content-Type header is given.") + raise AuthenticationFailureError diff --git a/src/mock_vws/_services_validators/database_id_validators.py b/src/mock_vws/_services_validators/database_id_validators.py new file mode 100644 index 000000000..d19dfbb2b --- /dev/null +++ b/src/mock_vws/_services_validators/database_id_validators.py @@ -0,0 +1,76 @@ +"""Validators for database IDs given in request paths.""" + +import logging +import re +from collections.abc import Iterable, Mapping + +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) +from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from mock_vws._services_validators.exceptions import ( + AuthenticationFailureError, +) +from mock_vws.database import CloudDatabase + +_LOGGER = logging.getLogger(name=__name__) +# The index of the database ID in +# ``/imagetargets/databases/{database_id}/reports/recoCounts``, split on "/". +_DATABASE_ID_PATH_INDEX = 3 + + +@beartype +def validate_database_id_matches_keys( + *, + request_path: str, + request_headers: Mapping[str, str], + request_body: bytes, + request_method: str, + databases: Iterable[AnyDatabase], +) -> None: + """Validate a database ID given in the request path. + + The ID must be the ID of the database which the request's server keys + belong to. + + Args: + request_path: The path of the request. + request_headers: The headers sent with the request. + request_body: The body of the request. + request_method: The HTTP method of the request. + databases: All Vuforia databases. + + Raises: + AuthenticationFailureError: The request path names a database other + than the one which the request's server keys belong to. + """ + if not re.fullmatch( + pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + string=request_path, + ): + return + + database = get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + + given_database_id = request_path.split(sep="/")[_DATABASE_ID_PATH_INDEX] + if ( + isinstance(database, CloudDatabase) + and database.database_id == given_database_id + ): + return + + _LOGGER.warning( + 'The database ID "%s" is not the ID of the database which the ' + "request's server keys belong to.", + given_database_id, + ) + raise AuthenticationFailureError diff --git a/src/mock_vws/_services_validators/date_validators.py b/src/mock_vws/_services_validators/date_validators.py index f037d9cb4..f5f773d97 100644 --- a/src/mock_vws/_services_validators/date_validators.py +++ b/src/mock_vws/_services_validators/date_validators.py @@ -1,74 +1,78 @@ -""" -Validators of the date header to use in the mock services API. -""" +"""Validators of the date header to use in the mock services API.""" import datetime +import logging +from collections.abc import Mapping from http import HTTPStatus -from typing import Dict +from zoneinfo import ZoneInfo -from backports.zoneinfo import ZoneInfo +from beartype import beartype -from mock_vws._services_validators.exceptions import Fail, RequestTimeTooSkewed +from mock_vws._services_validators.exceptions import ( + FailError, + RequestTimeTooSkewedError, +) +_LOGGER = logging.getLogger(name=__name__) -def validate_date_header_given(request_headers: Dict[str, str]) -> None: - """ - Validate the date header is given to a VWS endpoint. + +@beartype +def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: + """Validate the date header is given to a VWS endpoint. Args: request_headers: The headers sent with the request. Raises: - Fail: The date is not given. + FailError: The date is not given. """ - - if 'Date' in request_headers: + if "Date" in request_headers: return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning(msg="The date header is not given.") + raise FailError(status_code=HTTPStatus.BAD_REQUEST) -def validate_date_format(request_headers: Dict[str, str]) -> None: - """ - Validate the format of the date header given to a VWS endpoint. +@beartype +def validate_date_format(*, request_headers: Mapping[str, str]) -> None: + """Validate the format of the date header given to a VWS endpoint. Args: request_headers: The headers sent with the request. Raises: - Fail: The date is in the wrong format. + FailError: The date is in the wrong format. """ - - date_header = request_headers['Date'] - date_format = '%a, %d %b %Y %H:%M:%S GMT' + date_header = request_headers["Date"] + date_format = "%a, %d %b %Y %H:%M:%S GMT" try: - datetime.datetime.strptime(date_header, date_format) + datetime.datetime.strptime(date_header, date_format).astimezone() except ValueError as exc: - raise Fail(status_code=HTTPStatus.BAD_REQUEST) from exc + _LOGGER.warning(msg="The date header is in the wrong format.") + raise FailError(status_code=HTTPStatus.BAD_REQUEST) from exc -def validate_date_in_range(request_headers: Dict[str, str]) -> None: - """ - Validate the date header given to a VWS endpoint is in range. +@beartype +def validate_date_in_range(*, request_headers: Mapping[str, str]) -> None: + """Validate the date header given to a VWS endpoint is in range. Args: request_headers: The headers sent with the request. Raises: - RequestTimeTooSkewed: The date is out of range. + RequestTimeTooSkewedError: The date is out of range. """ - + gmt = ZoneInfo(key="GMT") date_from_header = datetime.datetime.strptime( - request_headers['Date'], - '%a, %d %b %Y %H:%M:%S GMT', - ) + request_headers["Date"], + "%a, %d %b %Y %H:%M:%S GMT", + ).replace(tzinfo=gmt) - gmt = ZoneInfo('GMT') now = datetime.datetime.now(tz=gmt) - date_from_header = date_from_header.replace(tzinfo=gmt) time_difference = now - date_from_header maximum_time_difference = datetime.timedelta(minutes=5) if abs(time_difference) >= maximum_time_difference: - raise RequestTimeTooSkewed + _LOGGER.warning(msg="The date header is out of range.") + raise RequestTimeTooSkewedError diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index ef0cea89b..aca038805 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -1,18 +1,32 @@ -""" -Exceptions to raise from validators. -""" +"""Exceptions to raise from validators.""" +import email.utils +import textwrap import uuid +from collections.abc import Mapping from http import HTTPStatus -from pathlib import Path + +from beartype import beartype from mock_vws._constants import ResultCodes from mock_vws._mock_common import json_dump -class UnknownTarget(Exception): +@beartype +class ValidatorError(Exception): + """ + A base class for exceptions thrown from mock Vuforia services + endpoints. """ - Exception raised when Vuforia returns a response with a result code + + status_code: HTTPStatus + response_text: str + headers: Mapping[str, str] + + +@beartype +class UnknownTargetError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'UnknownTarget'. """ @@ -21,21 +35,38 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.NOT_FOUND body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.UNKNOWN_TARGET.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.UNKNOWN_TARGET.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class ProjectInactive(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class ProjectInactiveError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'ProjectInactive'. """ @@ -44,21 +75,203 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.FORBIDDEN body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.PROJECT_INACTIVE.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.PROJECT_INACTIVE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class AuthenticationFailure(Exception): +@beartype +class RequestQuotaReachedError(ValidatorError): + """Exception raised when a database's request quota is exhausted. + + This response is based on Vuforia's documented status code and its common + VWS error response shape. It has not been verified against a real database + with an exhausted quota. """ - Exception raised when Vuforia returns a response with a result code + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in the response. + response_text: The response text to use in the response. + headers: The response headers. + """ + super().__init__() + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.REQUEST_QUOTA_REACHED.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class TooManyRequestsError(ValidatorError): + """Exception raised when a database exceeds its request rate limit.""" + + def __init__(self) -> None: + """Initialize a ``TooManyRequests`` response.""" + super().__init__() + self.status_code = HTTPStatus.TOO_MANY_REQUESTS + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TOO_MANY_REQUESTS.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class TargetQuotaReachedError(ValidatorError): + """Exception raised when a database's target quota is exhausted.""" + + def __init__(self) -> None: + """Initialize a ``TargetQuotaReached`` response.""" + super().__init__() + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_QUOTA_REACHED.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class ProjectSuspendedError(ValidatorError): + """Exception raised when a database has been suspended.""" + + def __init__(self) -> None: + """Initialize a ``ProjectSuspended`` response.""" + super().__init__() + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.PROJECT_SUSPENDED.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class ProjectHasNoApiAccessError(ValidatorError): + """Exception raised when a database cannot make API requests.""" + + def __init__(self) -> None: + """Initialize a ``ProjectHasNoApiAccess`` response.""" + super().__init__() + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.PROJECT_HAS_NO_API_ACCESS.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class AuthenticationFailureError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. """ @@ -67,44 +280,79 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.UNAUTHORIZED body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.AUTHENTICATION_FAILURE.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.AUTHENTICATION_FAILURE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class Fail(Exception): - """ - Exception raised when Vuforia returns a response with a result code 'Fail'. +@beartype +class FailError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code + 'Fail'. """ - def __init__(self, status_code: int) -> None: + def __init__(self, *, status_code: HTTPStatus) -> None: """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = status_code body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.FAIL.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.FAIL.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class MetadataTooLarge(Exception): - """ - Exception raised when Vuforia returns a response with a result code - 'MetadataTooLarge'. +@beartype +class BadRequestError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code + 'BadRequest'. """ def __init__(self) -> None: @@ -112,22 +360,39 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() - self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY + self.status_code = HTTPStatus.BAD_REQUEST body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.METADATA_TOO_LARGE.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.BAD_REQUEST.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class TargetNameExist(Exception): - """ - Exception raised when Vuforia returns a response with a result code - 'TargetNameExist'. +@beartype +class MetadataTooLargeError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code + 'MetadataTooLarge'. """ def __init__(self) -> None: @@ -135,24 +400,39 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() - self.status_code = HTTPStatus.FORBIDDEN + self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.TARGET_NAME_EXIST.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.METADATA_TOO_LARGE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) - -class OopsErrorOccurredResponse(Exception): - """ - Exception raised when VWS returns an HTML page which says "Oops, an error - occurred". - This has been seen to happen when the given name includes a bad character. +@beartype +class TargetNameExistError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code + 'TargetNameExist'. """ def __init__(self) -> None: @@ -160,21 +440,38 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() - self.status_code = HTTPStatus.INTERNAL_SERVER_ERROR - resources_dir = Path(__file__).parent.parent / 'resources' - filename = 'oops_error_occurred_response.html' - oops_resp_file = resources_dir / filename - text = str(oops_resp_file.read_text()) - self.response_text = text + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_NAME_EXIST.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } -class BadImage(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class BadImageError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'BadImage'. """ @@ -183,21 +480,38 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.BAD_IMAGE.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.BAD_IMAGE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class ImageTooLarge(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class ImageTooLargeError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'ImageTooLarge'. """ @@ -206,21 +520,38 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.IMAGE_TOO_LARGE.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.IMAGE_TOO_LARGE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class RequestTimeTooSkewed(Exception): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class RequestTimeTooSkewedError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'RequestTimeTooSkewed'. """ @@ -229,39 +560,74 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.FORBIDDEN body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.REQUEST_TIME_TOO_SKEWED.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.REQUEST_TIME_TOO_SKEWED.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", } - self.response_text = json_dump(body) -class ContentLengthHeaderTooLarge(Exception): +@beartype +class ContentLengthHeaderTooLargeError(ValidatorError): """ - Exception raised when the given content length header is too large. + Exception raised when the given content length header is too + large. """ - def __init__(self) -> None: + # We skip coverage here as running a test to cover this is very slow. + def __init__(self) -> None: # pragma: no cover """ Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() - self.status_code = HTTPStatus.GATEWAY_TIMEOUT - self.response_text = '' + self.status_code = HTTPStatus.REQUEST_TIMEOUT + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.response_text = "stream timeout" + self.headers = { + "Content-Length": str(object=len(self.response_text)), + "Date": date, + "server": "envoy", + "Content-Type": "text/plain", + "Connection": "close", + } -class ContentLengthHeaderNotInt(Exception): +@beartype +class ContentLengthHeaderNotIntError(ValidatorError): """ - Exception raised when the given content length header is not an integer. + Exception raised when the given content length header is not an + integer. """ def __init__(self) -> None: @@ -269,17 +635,70 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = '' + self.response_text = textwrap.dedent( + text="""\ + \r + 400 Bad Request\r + \r +

400 Bad Request

\r + \r + \r + """, + ) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "close", + "Content-Length": str(object=len(self.response_text)), + "Date": date, + "Server": "awselb/2.0", + "Content-Type": "text/html", + } + +@beartype +class UnnecessaryRequestBodyError(ValidatorError): + """Exception raised when a request body is given but not necessary.""" -class UnnecessaryRequestBody(Exception): + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.BAD_REQUEST + self.response_text = "" + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + } + + +@beartype +class TargetStatusNotSuccessError(ValidatorError): """ - Exception raised when a request body is given but not necessary. + Exception raised when trying to update a target that does not have a + success status. """ def __init__(self) -> None: @@ -287,9 +706,184 @@ def __init__(self) -> None: Attributes: status_code: The status code to use in a response if this is raised. - response_text: The response text to use in a response if this is + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_STATUS_NOT_SUCCESS.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class InvalidAcceptHeaderError(ValidatorError): + """Exception raised when an unsupported Accept header is given.""" + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is raised. """ super().__init__() self.status_code = HTTPStatus.BAD_REQUEST - self.response_text = '' + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_ACCEPT_HEADER.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class InvalidInstanceIdError(ValidatorError): + """Exception raised when an invalid instance_id is given.""" + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_INSTANCE_ID.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class InvalidTargetTypeError(ValidatorError): + """Exception raised when the target type is not valid for the + operation. + """ + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_TARGET_TYPE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class TargetStatusProcessingError(ValidatorError): + """Exception raised when trying to delete a target which is processing.""" + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.FORBIDDEN + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_STATUS_PROCESSING.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } diff --git a/src/mock_vws/_services_validators/image_validators.py b/src/mock_vws/_services_validators/image_validators.py index efec9415a..fadf0fea0 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -1,187 +1,259 @@ -""" -Image validators to use in the mock. -""" +"""Image validators to use in the mock.""" import binascii import io import json +import logging from http import HTTPStatus -from PIL import Image +from beartype import beartype from mock_vws._base64_decoding import decode_base64 +from mock_vws._image_opening import open_image from mock_vws._services_validators.exceptions import ( - BadImage, - Fail, - ImageTooLarge, + BadImageError, + FailError, + ImageTooLargeError, ) +_LOGGER = logging.getLogger(name=__name__) -def validate_image_format(request_body: bytes) -> None: - """ - Validate the format of the image given to a VWS endpoint. + +@beartype +def validate_image_integrity(*, request_body: bytes) -> None: + """Validate the integrity of the image given to a VWS endpoint. Args: request_body: The body of the request. Raises: - BadImage: The image is given and is not either a PNG or a JPEG. + BadImageError: The image is given and is not a valid image file. """ if not request_body: return request_text = request_body.decode() - image = json.loads(request_text).get('image') - + image = json.loads(s=request_text).get("image") if image is None: return decoded = decode_base64(encoded_data=image) - image_file = io.BytesIO(decoded) - pil_image = Image.open(image_file) - if pil_image.format in ('PNG', 'JPEG'): - return + image_file = io.BytesIO(initial_bytes=decoded) + with open_image(fp=image_file) as pil_image: + try: + pil_image.verify() + except SyntaxError as exc: + _LOGGER.warning(msg="The image is not a valid image file.") + raise BadImageError from exc - raise BadImage +@beartype +def validate_image_format(*, request_body: bytes) -> None: + """Validate the format of the image given to a VWS endpoint. + + Args: + request_body: The body of the request. -def validate_image_color_space(request_body: bytes) -> None: + Raises: + BadImageError: The image is given and is not either a PNG or a JPEG. """ - Validate the color space of the image given to a VWS endpoint. + if not request_body: + return + + request_text = request_body.decode() + image = json.loads(s=request_text).get("image") + + if image is None: + return + + decoded = decode_base64(encoded_data=image) + image_file = io.BytesIO(initial_bytes=decoded) + with open_image(fp=image_file) as pil_image: + if pil_image.format in {"PNG", "JPEG"}: + return + + _LOGGER.warning(msg="The image is not a PNG or JPEG.") + raise BadImageError + + +@beartype +def validate_image_color_space(*, request_body: bytes) -> None: + """Validate the color space of the image given to a VWS endpoint. Args: request_body: The body of the request. Raises: - BadImage: The image is given and is not in either the RGB or + BadImageError: The image is given and is not in either the RGB or greyscale color space. """ - if not request_body: return request_text = request_body.decode() - image = json.loads(request_text).get('image') + image = json.loads(s=request_text).get("image") if image is None: return decoded = decode_base64(encoded_data=image) - image_file = io.BytesIO(decoded) - pil_image = Image.open(image_file) - - if pil_image.mode in ('L', 'RGB'): - return + image_file = io.BytesIO(initial_bytes=decoded) + with open_image(fp=image_file) as pil_image: + if pil_image.mode in {"L", "RGB"}: + return - raise BadImage + _LOGGER.warning( + msg="The image is not in the RGB or greyscale color space.", + ) + raise BadImageError -def validate_image_size(request_body: bytes) -> None: - """ - Validate the file size of the image given to a VWS endpoint. +@beartype +def validate_image_size(*, request_body: bytes) -> None: + """Validate the file size of the image given to a VWS endpoint. Args: request_body: The body of the request. Raises: - ImageTooLarge: The image is given and is not under a certain file + ImageTooLargeError: The image is given and is not under a certain file size threshold. """ - if not request_body: return request_text = request_body.decode() - image = json.loads(request_text).get('image') + image = json.loads(s=request_text).get("image") if image is None: return decoded = decode_base64(encoded_data=image) - if len(decoded) <= 2359293: + max_allowed_size = 2_359_293 + if len(decoded) <= max_allowed_size: return - raise ImageTooLarge + _LOGGER.warning(msg="The image is too large.") + raise ImageTooLargeError -def validate_image_is_image(request_body: bytes) -> None: - """ - Validate that the given image data is actually an image file. +@beartype +def validate_image_pixel_count(*, request_body: bytes) -> None: + """Validate the number of pixels of the image given to a VWS endpoint. + + A small file can decode to a very large number of pixels, so this is not + covered by the file size limit. Args: request_body: The body of the request. Raises: - BadImage: Image data is given and it is not an image file. + ImageTooLargeError: The image is given and it has more than the + maximum number of pixels. """ + if not request_body: + return + + request_text = request_body.decode() + image = json.loads(s=request_text).get("image") + + if image is None: + return + + decoded = decode_base64(encoded_data=image) + image_file = io.BytesIO(initial_bytes=decoded) + + # This limit is not documented. + # It was found by binary search against a real database, and it holds + # whatever the image's aspect ratio and color space are. + max_allowed_pixels = 37_748_736 + with open_image(fp=image_file) as pil_image: + if pil_image.width * pil_image.height <= max_allowed_pixels: + return + _LOGGER.warning(msg="The image has too many pixels.") + raise ImageTooLargeError + + +@beartype +def validate_image_is_image(*, request_body: bytes) -> None: + """Validate that the given image data is actually an image file. + + Args: + request_body: The body of the request. + + Raises: + BadImageError: Image data is given and it is not an image file. + """ if not request_body: return request_text = request_body.decode() - image = json.loads(request_text).get('image') + image = json.loads(s=request_text).get("image") if image is None: return decoded = decode_base64(encoded_data=image) - image_file = io.BytesIO(decoded) + image_file = io.BytesIO(initial_bytes=decoded) try: - Image.open(image_file) + with open_image(fp=image_file) as _: + pass except OSError as exc: - raise BadImage from exc + _LOGGER.warning(msg="The image is not an image file.") + raise BadImageError from exc -def validate_image_encoding(request_body: bytes) -> None: - """ - Validate that the given image data can be base64 decoded. +@beartype +def validate_image_encoding(*, request_body: bytes) -> None: + """Validate that the given image data can be base64 decoded. Args: request_body: The body of the request. Raises: - Fail: Image data is given and it cannot be base64 decoded. + FailError: Image data is given and it cannot be base64 decoded. """ - if not request_body: return request_text = request_body.decode() - if 'image' not in json.loads(request_text): + if "image" not in json.loads(s=request_text): return - image = json.loads(request_text).get('image') + image = json.loads(s=request_text).get("image") try: decode_base64(encoded_data=image) except binascii.Error as exc: - raise Fail(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) from exc + _LOGGER.warning('Image data cannot be base64 decoded: "%s"', exc) + raise FailError(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) from exc -def validate_image_data_type(request_body: bytes) -> None: - """ - Validate that the given image data is a string. +@beartype +def validate_image_data_type(*, request_body: bytes) -> None: + """Validate that the given image data is a string. Args: request_body: The body of the request. Raises: - Fail: Image data is given and it is not a string. + FailError: Image data is given and it is not a string. """ - if not request_body: return request_text = request_body.decode() - if 'image' not in json.loads(request_text): + if "image" not in json.loads(s=request_text): return - image = json.loads(request_text).get('image') + image = json.loads(s=request_text).get("image") if isinstance(image, str): return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning('Image data is not a string: "%s"', image) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) diff --git a/src/mock_vws/_services_validators/instance_id_validators.py b/src/mock_vws/_services_validators/instance_id_validators.py new file mode 100644 index 000000000..9001e5f28 --- /dev/null +++ b/src/mock_vws/_services_validators/instance_id_validators.py @@ -0,0 +1,71 @@ +"""Validators for VuMark instance IDs.""" + +import json +import logging + +from beartype import beartype + +from mock_vws._services_validators.exceptions import ( + BadRequestError, + InvalidInstanceIdError, +) + +_LOGGER = logging.getLogger(name=__name__) + + +@beartype +def validate_instance_id_type(*, request_body: bytes) -> None: + """Validate the type of the instance_id data given to the VuMark + instance generation endpoint. + + Args: + request_body: The body of the request. + + Raises: + BadRequestError: There is instance_id data given to the endpoint + which is not a string. + """ + if not request_body: + return + + request_text = request_body.decode() + if "instance_id" not in json.loads(s=request_text): + return + + instance_id = json.loads(s=request_text)["instance_id"] + + if isinstance(instance_id, str): + return + + _LOGGER.warning( + msg='The value of "instance_id" is not a string. This is not allowed.', + ) + raise BadRequestError + + +@beartype +def validate_instance_id_not_empty(*, request_body: bytes) -> None: + """Validate that the instance_id data given to the VuMark instance + generation endpoint is not empty. + + Args: + request_body: The body of the request. + + Raises: + InvalidInstanceIdError: There is instance_id data given to the + endpoint which is an empty string. + """ + if not request_body: + return + + request_text = request_body.decode() + if "instance_id" not in json.loads(s=request_text): + return + + instance_id = json.loads(s=request_text)["instance_id"] + + if instance_id: + return + + _LOGGER.warning(msg='The value of "instance_id" is empty.') + raise InvalidInstanceIdError diff --git a/src/mock_vws/_services_validators/json_validators.py b/src/mock_vws/_services_validators/json_validators.py index a5aa28bb8..4e0549cd0 100644 --- a/src/mock_vws/_services_validators/json_validators.py +++ b/src/mock_vws/_services_validators/json_validators.py @@ -1,43 +1,69 @@ -""" -Validators for given JSON. -""" +"""Validators for given JSON.""" import json -from http import HTTPStatus +import logging +from http import HTTPMethod, HTTPStatus from json.decoder import JSONDecodeError -from requests_mock import POST, PUT +from beartype import beartype from mock_vws._services_validators.exceptions import ( - Fail, - UnnecessaryRequestBody, + BadRequestError, + FailError, + UnnecessaryRequestBodyError, ) +_LOGGER = logging.getLogger(name=__name__) -def validate_json( - request_body: bytes, - request_method: str, -) -> None: - """ - Validate that there is either no JSON given or the JSON given is valid. + +@beartype +def validate_body_given(*, request_body: bytes, request_method: str) -> None: + """Validate that no JSON is given for requests other than ``POST`` and + ``PUT`` requests. Args: request_body: The body of the request. request_method: The HTTP method of the request. Raises: - UnnecessaryRequestBody: A request body was given for an endpoint which - does not require one. - Fail: The request body includes invalid JSON. + UnnecessaryRequestBodyError: A request body was given for an endpoint + which does not require one. + FailError: The request body includes invalid JSON. """ - if not request_body: return - if request_method not in (POST, PUT): - raise UnnecessaryRequestBody + if request_method not in {HTTPMethod.POST, HTTPMethod.PUT}: + _LOGGER.warning( + msg=( + "A request body was given for an endpoint which does not " + "require one." + ), + ) + raise UnnecessaryRequestBodyError + + +@beartype +def validate_json(*, request_body: bytes, request_path: str) -> None: + """Validate that any given body is valid JSON. + + Args: + request_body: The body of the request. + request_path: The path of the request. + + Raises: + BadRequestError: The request body includes invalid JSON for the + VuMark instance generation endpoint. + FailError: The request body includes invalid JSON for other + endpoints. + """ + if not request_body: + return try: - json.loads(request_body.decode()) + json.loads(s=request_body.decode()) except JSONDecodeError as exc: - raise Fail(status_code=HTTPStatus.BAD_REQUEST) from exc + _LOGGER.warning(msg="The request body is not valid JSON.") + if request_path.endswith("/instances"): + raise BadRequestError from exc + raise FailError(status_code=HTTPStatus.BAD_REQUEST) from exc diff --git a/src/mock_vws/_services_validators/key_validators.py b/src/mock_vws/_services_validators/key_validators.py index 9869f2168..b198d6aa7 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -1,22 +1,25 @@ -""" -Validators for JSON keys. -""" +"""Validators for JSON keys.""" import json +import logging import re +from collections.abc import Iterable from dataclasses import dataclass -from http import HTTPStatus -from typing import Set +from http import HTTPMethod, HTTPStatus -from requests_mock import DELETE, GET, POST, PUT +from beartype import beartype -from .exceptions import Fail +from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from .exceptions import FailError -@dataclass +_LOGGER = logging.getLogger(name=__name__) + + +@beartype +@dataclass(frozen=True, kw_only=True) class _Route: - """ - A representation of a VWS route. + """A representation of a VWS route. Args: path_pattern: The end part of a URL pattern. E.g. `/targets` or @@ -28,18 +31,19 @@ class _Route: """ path_pattern: str - http_methods: Set[str] - mandatory_keys: Set[str] - optional_keys: Set[str] + http_methods: Iterable[HTTPMethod] + mandatory_keys: Iterable[str] + optional_keys: Iterable[str] +@beartype def validate_keys( + *, request_body: bytes, request_path: str, request_method: str, ) -> None: - """ - Validate the request keys given to a VWS endpoint. + """Validate the request keys given to a VWS endpoint. Args: request_body: The body of the request. @@ -47,111 +51,124 @@ def validate_keys( request_method: The HTTP method of the request. Raises: - Fail: Any given keys are not allowed, or if any required keys are + FailError: Any given keys are not allowed, or if any required keys are missing. """ - target_id_pattern = '[A-Za-z0-9]+' + target_id_pattern = "[A-Za-z0-9]+" add_target = _Route( - path_pattern='/targets', - http_methods={POST}, - mandatory_keys={'image', 'width', 'name'}, - optional_keys={'active_flag', 'application_metadata'}, + path_pattern="/targets", + http_methods={HTTPMethod.POST}, + mandatory_keys={"image", "width", "name"}, + optional_keys={"active_flag", "application_metadata"}, ) delete_target = _Route( - path_pattern=f'/targets/{target_id_pattern}', - http_methods={DELETE}, - mandatory_keys=set([]), - optional_keys=set([]), + path_pattern=f"/targets/{target_id_pattern}", + http_methods={HTTPMethod.DELETE}, + mandatory_keys=set(), + optional_keys=set(), ) database_summary = _Route( - path_pattern='/summary', - http_methods={GET}, - mandatory_keys=set([]), - optional_keys=set([]), + path_pattern="/summary", + http_methods={HTTPMethod.GET}, + mandatory_keys=set(), + optional_keys=set(), ) target_list = _Route( - path_pattern='/targets', - http_methods={GET}, - mandatory_keys=set([]), - optional_keys=set([]), + path_pattern="/targets", + http_methods={HTTPMethod.GET}, + mandatory_keys=set(), + optional_keys=set(), ) get_target = _Route( - path_pattern=f'/targets/{target_id_pattern}', - http_methods={GET}, - mandatory_keys=set([]), - optional_keys=set([]), + path_pattern=f"/targets/{target_id_pattern}", + http_methods={HTTPMethod.GET}, + mandatory_keys=set(), + optional_keys=set(), ) target_summary = _Route( - path_pattern=f'/summary/{target_id_pattern}', - http_methods={GET}, - mandatory_keys=set([]), - optional_keys=set([]), + path_pattern=f"/summary/{target_id_pattern}", + http_methods={HTTPMethod.GET}, + mandatory_keys=set(), + optional_keys=set(), ) get_duplicates = _Route( - path_pattern=f'/duplicates/{target_id_pattern}', - http_methods={GET}, - mandatory_keys=set([]), - optional_keys=set([]), + path_pattern=f"/duplicates/{target_id_pattern}", + http_methods={HTTPMethod.GET}, + mandatory_keys=set(), + optional_keys=set(), ) update_target = _Route( - path_pattern=f'/targets/{target_id_pattern}', - http_methods={PUT}, - mandatory_keys=set([]), + path_pattern=f"/targets/{target_id_pattern}", + http_methods={HTTPMethod.PUT}, + mandatory_keys=set(), optional_keys={ - 'active_flag', - 'application_metadata', - 'image', - 'name', - 'width', + "active_flag", + "application_metadata", + "image", + "name", + "width", }, ) - target_summary = _Route( - path_pattern=f'/summary/{target_id_pattern}', - http_methods={GET}, - mandatory_keys=set([]), - optional_keys=set([]), + generate_instance = _Route( + path_pattern=f"/targets/{target_id_pattern}/instances", + http_methods={HTTPMethod.POST}, + mandatory_keys={"instance_id"}, + optional_keys=set(), + ) + + reco_counts_report = _Route( + path_pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + http_methods={HTTPMethod.POST}, + mandatory_keys={"month"}, + optional_keys=set(), ) routes = ( add_target, + reco_counts_report, delete_target, database_summary, target_list, get_target, get_duplicates, update_target, + generate_instance, target_summary, ) - [matching_route] = [ + (matching_route,) = ( route for route in routes - if re.match(re.compile(route.path_pattern + '$'), request_path) - and request_method in route.http_methods - ] + if re.match( + pattern=re.compile(pattern=f"{route.path_pattern}$"), + string=request_path, + ) + and request_method in set(route.http_methods) + ) mandatory_keys = matching_route.mandatory_keys optional_keys = matching_route.optional_keys - allowed_keys = mandatory_keys.union(optional_keys) + allowed_keys = {*mandatory_keys, *optional_keys} - if request_body is None and not allowed_keys: + if not request_body and not allowed_keys: return request_text = request_body.decode() - request_json = json.loads(request_text) + request_json = json.loads(s=request_text) given_keys = set(request_json.keys()) all_given_keys_allowed = given_keys.issubset(allowed_keys) - all_mandatory_keys_given = mandatory_keys.issubset(given_keys) + all_mandatory_keys_given = set(mandatory_keys).issubset(set(given_keys)) if all_given_keys_allowed and all_mandatory_keys_given: return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning(msg="Invalid keys given to endpoint.") + raise FailError(status_code=HTTPStatus.BAD_REQUEST) diff --git a/src/mock_vws/_services_validators/metadata_validators.py b/src/mock_vws/_services_validators/metadata_validators.py index eb6e1431d..0695de3db 100644 --- a/src/mock_vws/_services_validators/metadata_validators.py +++ b/src/mock_vws/_services_validators/metadata_validators.py @@ -1,32 +1,40 @@ -""" -Validators for application metadata. -""" +"""Validators for application metadata.""" import binascii import json +import logging from http import HTTPStatus +from beartype import beartype + from mock_vws._base64_decoding import decode_base64 -from mock_vws._services_validators.exceptions import Fail, MetadataTooLarge +from mock_vws._services_validators.exceptions import ( + FailError, + MetadataTooLargeError, +) +_LOGGER = logging.getLogger(name=__name__) -def validate_metadata_size(request_body: bytes) -> None: - """ - Validate that the given application metadata is a string or 1024 * 1024 + +@beartype +def validate_metadata_size(*, request_body: bytes) -> None: + """Validate that the given application metadata is a string or 1024 * + 1024 bytes or fewer. Args: request_body: The body of the request. Raises: - MetadataTooLarge: Application metadata is given and it is too large. + MetadataTooLargeError: Application metadata is given and it is too + large. """ if not request_body: return request_text = request_body.decode() - request_json = json.loads(request_text) - application_metadata = request_json.get('application_metadata') + request_json = json.loads(s=request_text) + application_metadata = request_json.get("application_metadata") if application_metadata is None: return decoded = decode_base64(encoded_data=application_metadata) @@ -35,29 +43,30 @@ def validate_metadata_size(request_body: bytes) -> None: if len(decoded) <= max_metadata_bytes: return - raise MetadataTooLarge + _LOGGER.warning(msg="The application metadata is too large.") + raise MetadataTooLargeError -def validate_metadata_encoding(request_body: bytes) -> None: - """ - Validate that the given application metadata can be base64 decoded. +@beartype +def validate_metadata_encoding(*, request_body: bytes) -> None: + """Validate that the given application metadata can be base64 decoded. Args: request_body: The body of the request. Raises: - Fail: Application metadata is given and it cannot be base64 + FailError: Application metadata is given and it cannot be base64 decoded. """ if not request_body: return request_text = request_body.decode() - request_json = json.loads(request_text) - if 'application_metadata' not in request_json: + request_json = json.loads(s=request_text) + if "application_metadata" not in request_json: return - application_metadata = request_json.get('application_metadata') + application_metadata = request_json.get("application_metadata") if application_metadata is None: return @@ -65,30 +74,33 @@ def validate_metadata_encoding(request_body: bytes) -> None: try: decode_base64(encoded_data=application_metadata) except binascii.Error as exc: - raise Fail(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) from exc + _LOGGER.warning(msg="The application metadata is not base64 encoded.") + raise FailError(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) from exc -def validate_metadata_type(request_body: bytes) -> None: - """ - Validate that the given application metadata is a string or NULL. +@beartype +def validate_metadata_type(*, request_body: bytes) -> None: + """Validate that the given application metadata is a string or NULL. Args: request_body: The body of the request. Raises: - Fail: Application metadata is given and it is not a string or NULL. + FailError: Application metadata is given and it is not a string or + NULL. """ if not request_body: return request_text = request_body.decode() - request_json = json.loads(request_text) - if 'application_metadata' not in request_json: + request_json = json.loads(s=request_text) + if "application_metadata" not in request_json: return - application_metadata = request_json.get('application_metadata') + application_metadata = request_json.get("application_metadata") if application_metadata is None or isinstance(application_metadata, str): return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning(msg="The application metadata is not a string or NULL.") + raise FailError(status_code=HTTPStatus.BAD_REQUEST) diff --git a/src/mock_vws/_services_validators/name_validators.py b/src/mock_vws/_services_validators/name_validators.py index 5557b1154..abf1532c0 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -1,24 +1,33 @@ -""" -Validators for target names. -""" +"""Validators for target names.""" import json -from http import HTTPStatus +import logging +from collections.abc import Iterable, Mapping +from http import HTTPMethod, HTTPStatus +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) from mock_vws._services_validators.exceptions import ( - Fail, - OopsErrorOccurredResponse, - TargetNameExist, + FailError, + TargetNameExistError, ) +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_name_characters_in_range( + *, request_body: bytes, request_method: str, request_path: str, ) -> None: - """ - Validate the characters in the name argument given to a VWS endpoint. + """Validate the characters in the name argument given to a VWS + endpoint. Args: request_body: The body of the request. @@ -26,77 +35,201 @@ def validate_name_characters_in_range( request_path: The path to the endpoint. Raises: - OopsErrorOccurredResponse: Characters are out of range and the request - is trying to make a new target. - TargetNameExist: Characters are out of range and the request is for - another endpoint. + FailError: Characters are out of range and the request is trying to + make a new target. + TargetNameExistError: Characters are out of range and the request is + for another endpoint. """ - if not request_body: return request_text = request_body.decode() - if 'name' not in json.loads(request_text): + if "name" not in json.loads(s=request_text): return - name = json.loads(request_text)['name'] + name = json.loads(s=request_text)["name"] - if all(ord(character) <= 65535 for character in name): + max_character_ord = 65535 + if all(ord(character) <= max_character_ord for character in name): return - if (request_method, request_path) == ('POST', '/targets'): - raise OopsErrorOccurredResponse + if (request_method, request_path) == (HTTPMethod.POST, "/targets"): + _LOGGER.warning(msg="Characters are out of range.") + raise FailError(status_code=HTTPStatus.INTERNAL_SERVER_ERROR) - raise TargetNameExist + _LOGGER.warning(msg="Characters are out of range.") + raise TargetNameExistError -def validate_name_type(request_body: bytes) -> None: - """ - Validate the type of the name argument given to a VWS endpoint. +@beartype +def validate_name_type(*, request_body: bytes) -> None: + """Validate the type of the name argument given to a VWS endpoint. Args: request_body: The body of the request. Raises: - Fail: A name is given and it is not a string. + FailError: A name is given and it is not a string. """ - if not request_body: return request_text = request_body.decode() - if 'name' not in json.loads(request_text): + if "name" not in json.loads(s=request_text): return - name = json.loads(request_text)['name'] + name = json.loads(s=request_text)["name"] if isinstance(name, str): return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning(msg="Name is not a string.") + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + + +@beartype +def validate_name_length(*, request_body: bytes) -> None: + """Validate the length of the name argument given to a VWS endpoint. + + Args: + request_body: The body of the request. + + Raises: + FailError: A name is given and it is not a between 1 and 64 characters + in length. + """ + if not request_body: + return + + request_text = request_body.decode() + if "name" not in json.loads(s=request_text): + return + + name = json.loads(s=request_text)["name"] + + max_length = 64 + if name and len(name) <= max_length: + return + + _LOGGER.warning(msg="Name is not between 1 and 64 characters in length.") + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + + +@beartype +def validate_name_does_not_exist_new_target( + *, + databases: Iterable[AnyDatabase], + request_body: bytes, + request_headers: Mapping[str, str], + request_method: str, + request_path: str, +) -> None: + """Validate that the name does not exist for any existing target. + Args: + databases: All Vuforia databases. + request_body: The body of the request. + request_headers: The headers sent with the request. + request_method: The HTTP method the request is using. + request_path: The path to the endpoint. -def validate_name_length(request_body: bytes) -> None: + Raises: + TargetNameExistError: The target name already exists. """ - Validate the length of the name argument given to a VWS endpoint. + if not request_body: + return + + request_text = request_body.decode() + if "name" not in json.loads(s=request_text): + return + + split_path = request_path.split(sep="/") + + split_path_no_target_id_length = 2 + if len(split_path) != split_path_no_target_id_length: + return + + name = json.loads(s=request_text)["name"] + database = get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + + matching_name_targets = [ + target + for target in database.not_deleted_targets + if target.name == name + ] + + if not matching_name_targets: + return + + _LOGGER.warning(msg="Target name already exists.") + raise TargetNameExistError + + +@beartype +def validate_name_does_not_exist_existing_target( + *, + request_headers: Mapping[str, str], + request_body: bytes, + request_method: str, + request_path: str, + databases: Iterable[AnyDatabase], +) -> None: + """Validate that the name does not exist for any existing target apart + from + the one being updated. Args: + databases: All Vuforia databases. request_body: The body of the request. + request_headers: The headers sent with the request. + request_method: The HTTP method the request is using. + request_path: The path to the endpoint. Raises: - Fail: A name is given and it is not a between 1 and 64 characters in - length. + TargetNameExistError: The target name is not the same as the name of + the target being updated but it is the same as another target. """ if not request_body: return request_text = request_body.decode() - if 'name' not in json.loads(request_text): + if "name" not in json.loads(s=request_text): + return + + split_path = request_path.split(sep="/") + split_path_no_target_id_length = 2 + if len(split_path) == split_path_no_target_id_length: return - name = json.loads(request_text)['name'] + target_id = split_path[-1] + + name = json.loads(s=request_text)["name"] + database = get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + + matching_name_targets = [ + target + for target in database.not_deleted_targets + if target.name == name + ] + + if not matching_name_targets: + return - if name and len(name) < 65: + (matching_name_target,) = matching_name_targets + if matching_name_target.target_id == target_id: return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning(msg="Name already exists for another target.") + raise TargetNameExistError diff --git a/src/mock_vws/_services_validators/project_state_validators.py b/src/mock_vws/_services_validators/project_state_validators.py index bf09df887..c22d6a2f8 100644 --- a/src/mock_vws/_services_validators/project_state_validators.py +++ b/src/mock_vws/_services_validators/project_state_validators.py @@ -1,24 +1,37 @@ -""" -Validators for the project state. -""" +"""Validators for the project state.""" -from typing import Dict, Set +import logging +from collections.abc import Iterable, Mapping +from http import HTTPMethod -from mock_vws._database_matchers import get_database_matching_server_keys -from mock_vws._services_validators.exceptions import ProjectInactive -from mock_vws.database import VuforiaDatabase +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) +from mock_vws._services_validators.exceptions import ( + ProjectHasNoApiAccessError, + ProjectInactiveError, + ProjectSuspendedError, + ValidatorError, +) +from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.states import States +_LOGGER = logging.getLogger(name=__name__) + +@beartype def validate_project_state( + *, request_path: str, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Set[VuforiaDatabase], + databases: Iterable[AnyDatabase], ) -> None: - """ - Validate the state of the project. + """Validate the state of the project. Args: request_path: The path of the request. @@ -28,8 +41,8 @@ def validate_project_state( databases: All Vuforia databases. Raises: - ProjectInactive: The project is inactive and this endpoint does not - work with inactive projects. + ProjectInactiveError: The project is inactive and this endpoint does + not work with inactive projects. """ database = get_database_matching_server_keys( request_headers=request_headers, @@ -39,11 +52,25 @@ def validate_project_state( databases=databases, ) - assert isinstance(database, VuforiaDatabase) + state_errors: dict[States, type[ValidatorError]] = { + States.PROJECT_HAS_NO_API_ACCESS: ProjectHasNoApiAccessError, + States.PROJECT_SUSPENDED: ProjectSuspendedError, + } + if error := state_errors.get(database.state): + raise error + if database.state != States.PROJECT_INACTIVE: return - if request_method == 'GET' and 'duplicates' not in request_path: + if ( + isinstance(database, CloudDatabase) + and request_method == HTTPMethod.GET + and "duplicates" not in request_path + ): + return + + if isinstance(database, VuMarkDatabase): return - raise ProjectInactive + _LOGGER.warning(msg="The project is inactive.") + raise ProjectInactiveError diff --git a/src/mock_vws/_services_validators/request_quota_validators.py b/src/mock_vws/_services_validators/request_quota_validators.py new file mode 100644 index 000000000..2e0f87b38 --- /dev/null +++ b/src/mock_vws/_services_validators/request_quota_validators.py @@ -0,0 +1,42 @@ +"""Validators for the VWS request quota. + +This behavior cannot be verified against the real Vuforia Web Services +without deliberately exhausting a database's request quota. It implements the +publicly documented behavior so that users can exercise their application's +quota-error handling with the mock. +""" + +from collections.abc import Iterable, Mapping + +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) +from mock_vws.database import CloudDatabase + +from .exceptions import RequestQuotaReachedError + + +@beartype +def validate_request_quota( + *, + request_headers: Mapping[str, str], + request_body: bytes, + request_method: str, + request_path: str, + databases: Iterable[AnyDatabase], +) -> None: + """Raise an error if the matching cloud database has no request + quota. + """ + database = get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + if isinstance(database, CloudDatabase) and database.request_quota == 0: + raise RequestQuotaReachedError diff --git a/src/mock_vws/_services_validators/request_rate_validators.py b/src/mock_vws/_services_validators/request_rate_validators.py new file mode 100644 index 000000000..1ae5a05fd --- /dev/null +++ b/src/mock_vws/_services_validators/request_rate_validators.py @@ -0,0 +1,155 @@ +"""Validators for the VWS request rates.""" + +import re +import threading +from collections import deque +from collections.abc import Callable, Iterable, Mapping +from http import HTTPMethod + +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) +from mock_vws.database import CloudDatabase +from mock_vws.request_rate_limits import ( + RateLimitedEndpoint, + RequestRateLimit, +) + +from .exceptions import TooManyRequestsError + +_WINDOW_SECONDS = 1.0 + +_GET_TARGET_PATH_PATTERN = re.compile(pattern=r"^/targets/[^/]+$") +_GET_DUPLICATES_PATH_PATTERN = re.compile(pattern=r"^/duplicates/[^/]+$") + + +@beartype +def _rate_limited_endpoint( + *, + request_method: str, + request_path: str, +) -> RateLimitedEndpoint: + """Return the endpoint group which a request belongs to.""" + path = request_path.split(sep="?", maxsplit=1)[0] + if request_method == HTTPMethod.GET: + if path == "/targets": + return RateLimitedEndpoint.LIST_TARGETS + if _GET_TARGET_PATH_PATTERN.fullmatch(string=path): + return RateLimitedEndpoint.GET_TARGET + if _GET_DUPLICATES_PATH_PATTERN.fullmatch(string=path): + return RateLimitedEndpoint.GET_DUPLICATES + return RateLimitedEndpoint.OTHER + + +@beartype +class RequestRateLimiter: + """Track request times independently for each cloud database.""" + + def __init__( + self, + *, + time_function: Callable[[], float], + ) -> None: + """Initialize an empty rate limiter.""" + self._request_times: dict[tuple[str, str], deque[float]] = {} + self._lock = threading.Lock() + self._time_function = time_function + + def validate( + self, + *, + database: CloudDatabase, + endpoint: RateLimitedEndpoint, + ) -> None: + """Raise an error if a rate limit for the request is exhausted. + + Args: + database: The database which the request is made against. + endpoint: The endpoint group which the request belongs to. + + Raises: + TooManyRequestsError: A limit which applies to the request has + been reached. + """ + # The ``requests_per_second_limit`` setting applies to every VWS + # request made against the database, no matter which endpoint is + # used, and so it has a bucket of its own. + buckets: list[tuple[str, RequestRateLimit]] = [] + if database.requests_per_second_limit is not None: + buckets.append( + ( + "ALL_ENDPOINTS", + RequestRateLimit( + max_requests=database.requests_per_second_limit, + window_seconds=_WINDOW_SECONDS, + ), + ) + ) + + if database.request_rate_limits is not None: + endpoint_limit = database.request_rate_limits.for_endpoint( + endpoint=endpoint, + ) + if endpoint_limit is not None: + (limit_endpoint, limit) = endpoint_limit + buckets.append((limit_endpoint.name, limit)) + + with self._lock: + now = self._time_function() + request_times_for_buckets: list[deque[float]] = [] + for bucket_name, limit in buckets: + request_times = self._request_times.setdefault( + (database.server_access_key, bucket_name), + deque(), + ) + window_start = now - limit.window_seconds + while request_times and request_times[0] <= window_start: + request_times.popleft() + + if len(request_times) >= limit.max_requests: + raise TooManyRequestsError + + request_times_for_buckets.append(request_times) + + for request_times in request_times_for_buckets: + request_times.append(now) + + def remove_database(self, *, database: CloudDatabase) -> None: + """Discard request history for a removed database.""" + with self._lock: + self._request_times = { + key: value + for key, value in self._request_times.items() + if key[0] != database.server_access_key + } + + +@beartype +def validate_request_rate( + *, + request_headers: Mapping[str, str], + request_body: bytes, + request_method: str, + request_path: str, + databases: Iterable[AnyDatabase], + request_rate_limiter: RequestRateLimiter, +) -> None: + """Apply the configured request rates to the matching cloud + database. + """ + database = get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + if isinstance(database, CloudDatabase): + endpoint = _rate_limited_endpoint( + request_method=request_method, + request_path=request_path, + ) + request_rate_limiter.validate(database=database, endpoint=endpoint) diff --git a/src/mock_vws/_services_validators/target_quota_validators.py b/src/mock_vws/_services_validators/target_quota_validators.py new file mode 100644 index 000000000..210efe22d --- /dev/null +++ b/src/mock_vws/_services_validators/target_quota_validators.py @@ -0,0 +1,41 @@ +"""Validators for the VWS target quota.""" + +from collections.abc import Iterable, Mapping +from http import HTTPMethod + +from beartype import beartype + +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) +from mock_vws.database import CloudDatabase + +from .exceptions import TargetQuotaReachedError + + +@beartype +def validate_target_quota( + *, + request_headers: Mapping[str, str], + request_body: bytes, + request_method: str, + request_path: str, + databases: Iterable[AnyDatabase], +) -> None: + """Raise an error when adding a target would exceed the quota.""" + if request_method != HTTPMethod.POST or request_path != "/targets": + return + + database = get_database_matching_server_keys( + request_headers=request_headers, + request_body=request_body, + request_method=request_method, + request_path=request_path, + databases=databases, + ) + if ( + isinstance(database, CloudDatabase) + and len(database.not_deleted_targets) >= database.target_quota + ): + raise TargetQuotaReachedError diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index 49a23aec1..005649cf8 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -1,23 +1,33 @@ -""" -Validators for given target IDs. -""" -from typing import Dict, Set +"""Validators for given target IDs.""" -from mock_vws._database_matchers import get_database_matching_server_keys -from mock_vws._services_validators.exceptions import UnknownTarget -from mock_vws.database import VuforiaDatabase +import logging +import re +from collections.abc import Iterable, Mapping +from beartype import beartype +from mock_vws._database_matchers import ( + AnyDatabase, + get_database_matching_server_keys, +) +from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN +from mock_vws._services_validators.exceptions import UnknownTargetError + +_LOGGER = logging.getLogger(name=__name__) +_TARGETS_WITH_INSTANCE_PATH_LENGTH = 4 + + +@beartype def validate_target_id_exists( + *, request_path: str, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: Set[VuforiaDatabase], + databases: Iterable[AnyDatabase], ) -> None: - """ - Validate that if a target ID is given, it exists in the database matching - the request. + """Validate that if a target ID is given, it exists in the database + matching the request. Args: request_path: The path of the request. @@ -27,14 +37,28 @@ def validate_target_id_exists( databases: All Vuforia databases. Raises: - UnknownTarget: There are no matching targets for a given target ID. + UnknownTargetError: There are no matching targets for a given target + ID. """ - split_path = request_path.split('/') + if re.fullmatch( + pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + string=request_path, + ): + return + + split_path = request_path.split(sep="/") - if len(split_path) == 2: + request_path_no_target_id_length = 2 + if len(split_path) == request_path_no_target_id_length: return target_id = split_path[-1] + if ( + len(split_path) == _TARGETS_WITH_INSTANCE_PATH_LENGTH + and split_path[-3] == "targets" + and split_path[-1] == "instances" + ): + target_id = split_path[-2] database = get_database_matching_server_keys( request_headers=request_headers, request_body=request_body, @@ -43,13 +67,11 @@ def validate_target_id_exists( databases=databases, ) - assert isinstance(database, VuforiaDatabase) - - try: - [_] = [ - target - for target in database.targets - if target.target_id == target_id and not target.delete_date - ] - except ValueError as exc: - raise UnknownTarget from exc + matching_targets = [ + target + for target in database.not_deleted_targets + if target.target_id == target_id + ] + if not matching_targets: + _LOGGER.warning('The target ID "%s" does not exist.', target_id) + raise UnknownTargetError diff --git a/src/mock_vws/_services_validators/width_validators.py b/src/mock_vws/_services_validators/width_validators.py index 39390d578..ab47947d2 100644 --- a/src/mock_vws/_services_validators/width_validators.py +++ b/src/mock_vws/_services_validators/width_validators.py @@ -1,36 +1,38 @@ -""" -Validators for the width field. -""" +"""Validators for the width field.""" import json -import numbers +import logging from http import HTTPStatus -from mock_vws._services_validators.exceptions import Fail +from beartype import beartype +from mock_vws._services_validators.exceptions import FailError + +_LOGGER = logging.getLogger(name=__name__) -def validate_width(request_body: bytes) -> None: - """ - Validate the width argument given to a VWS endpoint. + +@beartype +def validate_width(*, request_body: bytes) -> None: + """Validate the width argument given to a VWS endpoint. Args: request_body: The body of the request. Raises: - Fail: Width is given and is not a positive number. + FailError: Width is given and is not a positive number. """ - if not request_body: return request_text = request_body.decode() - if 'width' not in json.loads(request_text): + if "width" not in json.loads(s=request_text): return - width = json.loads(request_text).get('width') + width = json.loads(s=request_text).get("width") - width_is_number = isinstance(width, numbers.Number) + width_is_number = isinstance(width, int | float) width_positive = width_is_number and width > 0 if not width_positive: - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _LOGGER.warning(msg="Width is not a positive number.") + raise FailError(status_code=HTTPStatus.BAD_REQUEST) diff --git a/src/mock_vws/cloud_query.py b/src/mock_vws/cloud_query.py new file mode 100644 index 000000000..3aefa99fd --- /dev/null +++ b/src/mock_vws/cloud_query.py @@ -0,0 +1,22 @@ +"""Public configuration types for the Vuforia Cloud Query API.""" + +from dataclasses import dataclass, field + +from beartype import beartype + + +@beartype +@dataclass(frozen=True, kw_only=True) +class CloudQueryFailureResponse: + """A failure response returned by the Cloud Query API mock. + + Args: + status_code: The HTTP status code to return. + headers: The HTTP response headers to return. + body: The raw response body. String bodies are encoded as UTF-8 by + the HTTP backend; byte bodies are returned unchanged. + """ + + status_code: int + headers: dict[str, str] = field(default_factory=dict[str, str]) + body: str | bytes = b"" diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index a22939d26..667932f9a 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -1,34 +1,314 @@ -""" -Utilities for managing mock Vuforia databases. -""" +"""Utilities for managing mock Vuforia databases.""" import uuid +from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Set +from typing import NotRequired, Self, TypedDict -from .states import States -from .target import Target +from beartype import beartype +from mock_vws._constants import TargetStatuses +from mock_vws.database_type import DatabaseType +from mock_vws.request_rate_limits import ( + RequestRateLimits, + RequestRateLimitsDict, +) +from mock_vws.states import States +from mock_vws.target import ( + ImageTarget, + ImageTargetDict, + VuMarkTarget, + VuMarkTargetDict, +) + +@beartype +class CloudDatabaseDict(TypedDict): + """A dictionary type which represents a cloud database.""" + + database_id: str + database_name: str + server_access_key: str + server_secret_key: str + client_access_key: str + client_secret_key: str + state_name: str + database_type_name: str + targets: Iterable[ImageTargetDict] + request_quota: NotRequired[int] + reco_threshold: NotRequired[int] + current_month_recos: NotRequired[int] + previous_month_recos: NotRequired[int] + total_recos: NotRequired[int] + target_quota: NotRequired[int] + requests_per_second_limit: NotRequired[int | None] + request_rate_limits: NotRequired[RequestRateLimitsDict | None] + + +@beartype +class VuMarkDatabaseDict(TypedDict): + """A dictionary type which represents a VuMark database.""" + + database_name: str + server_access_key: str + server_secret_key: str + vumark_targets: Iterable[VuMarkTargetDict] + state_name: str + + +@beartype def _random_hex() -> str: - """ - Return a random hex value. - """ + """Return a random hex value.""" return uuid.uuid4().hex -@dataclass(eq=True, frozen=True) -class VuforiaDatabase: - """ - Credentials for VWS APIs. +@beartype +@dataclass(eq=True, frozen=True, kw_only=True) +class CloudDatabase: + """Credentials for VWS APIs. + + Args: + database_id: The identifier of a VWS target manager database. Defaults + to a random string. Endpoints which name a database in their path, + such as the reco counts report endpoint, accept only the identifier + of the database which the request's server keys belong to. + database_name: The name of a VWS target manager database name. Defaults + to a random string. + server_access_key: A VWS server access key. Defaults to a random + string. + server_secret_key: A VWS server secret key. Defaults to a random + string. + client_access_key: A VWS client access key. Defaults to a random + string. + client_secret_key: A VWS client secret key. Defaults to a random + string. + state: The state of the database. + request_quota: The request quota. Set this to ``0`` to make VWS + endpoints return ``RequestQuotaReached``. + target_quota: The target quota. When the database contains this many + targets, adding another returns ``TargetQuotaReached``. + requests_per_second_limit: The maximum number of VWS requests accepted + in a rolling one-second window, across all VWS endpoints. Set this + to ``0`` to make VWS endpoints return ``TooManyRequests``. By + default, the mock does not apply this limit. + request_rate_limits: Request rate limits which apply to individual + groups of VWS endpoints, tracked separately from each other and + from ``requests_per_second_limit``. Set this to + :data:`mock_vws.request_rate_limits.DOCUMENTED_REQUEST_RATE_LIMITS` + to apply the limits which Vuforia documents. By default, the mock + does not apply per-endpoint request limits. """ # We hide a few things in the ``repr`` with ``repr=False`` so that they do # not show up in CI logs. + database_id: str = field(default_factory=_random_hex, repr=False) database_name: str = field(default_factory=_random_hex, repr=False) server_access_key: str = field(default_factory=_random_hex, repr=False) server_secret_key: str = field(default_factory=_random_hex, repr=False) client_access_key: str = field(default_factory=_random_hex, repr=False) client_secret_key: str = field(default_factory=_random_hex, repr=False) - targets: Set[Target] = field(default_factory=set, hash=False) + # We have ``targets`` as ``hash=False`` so that we can have the class as + # ``frozen=True`` while still being able to keep the interface we want. + # In particular, we might want to inspect the ``database`` object's targets + # as they change via API requests. + targets: set[ImageTarget] = field( + default_factory=set[ImageTarget], + hash=False, + ) + state: States = States.WORKING + database_type: DatabaseType = DatabaseType.CLOUD_RECO + + request_quota: int = 100000 + reco_threshold: int = 1000 + current_month_recos: int = 0 + previous_month_recos: int = 0 + total_recos: int = 0 + target_quota: int = 1000 + requests_per_second_limit: int | None = None + request_rate_limits: RequestRateLimits | None = None + + def to_dict(self) -> CloudDatabaseDict: + """Dump a target to a dictionary which can be loaded as JSON.""" + targets: list[ImageTargetDict] = [ + target.to_dict() for target in self.targets + ] + request_rate_limits: RequestRateLimitsDict | None = ( + None + if self.request_rate_limits is None + else self.request_rate_limits.to_dict() + ) + return { + "database_id": self.database_id, + "database_name": self.database_name, + "server_access_key": self.server_access_key, + "server_secret_key": self.server_secret_key, + "client_access_key": self.client_access_key, + "client_secret_key": self.client_secret_key, + "state_name": self.state.name, + "database_type_name": self.database_type.name, + "targets": targets, + "request_quota": self.request_quota, + "reco_threshold": self.reco_threshold, + "current_month_recos": self.current_month_recos, + "previous_month_recos": self.previous_month_recos, + "total_recos": self.total_recos, + "target_quota": self.target_quota, + "requests_per_second_limit": self.requests_per_second_limit, + "request_rate_limits": request_rate_limits, + } + + def get_target(self, target_id: str) -> ImageTarget: + """Return a target from the database with the given ID.""" + (target,) = ( + target for target in self.targets if target.target_id == target_id + ) + return target + + @classmethod + def from_dict(cls, database_dict: CloudDatabaseDict) -> Self: + """Load a database from a dictionary.""" + targets: set[ImageTarget] = { + ImageTarget.from_dict(target_dict=target_dict) + for target_dict in database_dict["targets"] + } + request_rate_limits_dict = database_dict.get("request_rate_limits") + request_rate_limits = ( + None + if request_rate_limits_dict is None + else RequestRateLimits.from_dict( + limits_dict=request_rate_limits_dict + ) + ) + + return cls( + database_id=database_dict["database_id"], + database_name=database_dict["database_name"], + server_access_key=database_dict["server_access_key"], + server_secret_key=database_dict["server_secret_key"], + client_access_key=database_dict["client_access_key"], + client_secret_key=database_dict["client_secret_key"], + state=States[database_dict["state_name"]], + database_type=DatabaseType[database_dict["database_type_name"]], + targets=targets, + request_quota=database_dict.get("request_quota", 100000), + reco_threshold=database_dict.get("reco_threshold", 1000), + current_month_recos=database_dict.get("current_month_recos", 0), + previous_month_recos=database_dict.get("previous_month_recos", 0), + total_recos=database_dict.get("total_recos", 0), + target_quota=database_dict.get("target_quota", 1000), + requests_per_second_limit=database_dict.get( + "requests_per_second_limit" + ), + request_rate_limits=request_rate_limits, + ) + + @property + def not_deleted_targets(self) -> set[ImageTarget]: + """All targets which have not been deleted.""" + return {target for target in self.targets if not target.delete_date} + + @property + def active_targets(self) -> set[ImageTarget]: + """All active targets.""" + return { + target + for target in self.not_deleted_targets + if target.status == TargetStatuses.SUCCESS.value + and target.active_flag + } + + @property + def inactive_targets(self) -> set[ImageTarget]: + """All inactive targets.""" + return { + target + for target in self.not_deleted_targets + if target.status == TargetStatuses.SUCCESS.value + and not target.active_flag + } + + @property + def failed_targets(self) -> set[ImageTarget]: + """All failed targets.""" + return { + target + for target in self.not_deleted_targets + if target.status == TargetStatuses.FAILED.value + } + + @property + def processing_targets(self) -> set[ImageTarget]: + """All processing targets.""" + return { + target + for target in self.not_deleted_targets + if target.status == TargetStatuses.PROCESSING.value + } + + +@beartype +@dataclass(eq=True, frozen=True, kw_only=True) +class VuMarkDatabase: + """Credentials for the VuMark generation API. + + Args: + database_name: The name of a VWS target manager database name. Defaults + to a random string. + server_access_key: A VWS server access key. Defaults to a random + string. + server_secret_key: A VWS server secret key. Defaults to a random + string. + """ + + database_name: str = field(default_factory=_random_hex, repr=False) + server_access_key: str = field(default_factory=_random_hex, repr=False) + server_secret_key: str = field(default_factory=_random_hex, repr=False) + # We have ``vumark_targets`` as ``hash=False`` so that we can have the + # class as ``frozen=True`` while still being able to keep the interface + # we want. + vumark_targets: set[VuMarkTarget] = field( + default_factory=set[VuMarkTarget], + hash=False, + ) state: States = States.WORKING + + def get_vumark_target(self, target_id: str) -> VuMarkTarget: + """Return a VuMark target from the database with the given ID.""" + (target,) = ( + target + for target in self.vumark_targets + if target.target_id == target_id + ) + return target + + def to_dict(self) -> VuMarkDatabaseDict: + """Dump a VuMark database to a dictionary which can be loaded as + JSON. + """ + vumark_targets = [target.to_dict() for target in self.vumark_targets] + return { + "database_name": self.database_name, + "server_access_key": self.server_access_key, + "server_secret_key": self.server_secret_key, + "vumark_targets": vumark_targets, + "state_name": self.state.name, + } + + @classmethod + def from_dict(cls, database_dict: VuMarkDatabaseDict) -> Self: + """Load a VuMark database from a dictionary.""" + return cls( + database_name=database_dict["database_name"], + server_access_key=database_dict["server_access_key"], + server_secret_key=database_dict["server_secret_key"], + vumark_targets={ + VuMarkTarget.from_dict(target_dict=target_dict) + for target_dict in database_dict["vumark_targets"] + }, + state=States[database_dict["state_name"]], + ) + + @property + def not_deleted_targets(self) -> set[VuMarkTarget]: + """All VuMark targets.""" + return set(self.vumark_targets) diff --git a/src/mock_vws/database_type.py b/src/mock_vws/database_type.py new file mode 100644 index 000000000..bd72733d4 --- /dev/null +++ b/src/mock_vws/database_type.py @@ -0,0 +1,13 @@ +"""Vuforia database types.""" + +from enum import StrEnum, auto, unique + +from beartype import beartype + + +@beartype +@unique +class DatabaseType(StrEnum): + """Constants representing various database types.""" + + CLOUD_RECO = auto() diff --git a/src/mock_vws/decorators.py b/src/mock_vws/decorators.py new file mode 100644 index 000000000..e69972d1d --- /dev/null +++ b/src/mock_vws/decorators.py @@ -0,0 +1,323 @@ +"""Decorators for using the mock.""" + +import re +import time +from collections.abc import Callable, Mapping +from contextlib import ContextDecorator +from typing import TYPE_CHECKING, Any, Literal, Self +from urllib.parse import urlparse + +import requests +from beartype import BeartypeConf, beartype +from requests import PreparedRequest +from responses import RequestsMock + +from mock_vws._mock_common import MissingSchemeError, RequestData +from mock_vws._requests_mock_server.mock_web_query_api import ( + MockVuforiaWebQueryAPI, +) +from mock_vws._requests_mock_server.mock_web_services_api import ( + MockVuforiaWebServicesAPI, +) +from mock_vws._respx_mock_server.decorators import start_respx_router +from mock_vws.cloud_query import CloudQueryFailureResponse +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.image_matchers import ( + ImageMatcher, + StructuralSimilarityMatcher, +) +from mock_vws.model_target import ( + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) +from mock_vws.target_manager import TargetManager +from mock_vws.target_raters import ( + BrisqueTargetTrackingRater, + TargetTrackingRater, +) +from mock_vws.vumark import VuMarkGenerationFailure + +if TYPE_CHECKING: + import respx + +_ResponseType = tuple[int, Mapping[str, str], str | bytes] +_MockCallback = Callable[[RequestData], _ResponseType] +_ResponsesCallback = Callable[[PreparedRequest], _ResponseType] + +_STRUCTURAL_SIMILARITY_MATCHER = StructuralSimilarityMatcher() +_BRISQUE_TRACKING_RATER = BrisqueTargetTrackingRater() + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class MockVWS(ContextDecorator): + """Route requests to Vuforia's Web Service APIs to fakes of those APIs. + + Works with both ``requests`` and ``httpx``. + """ + + def __init__( + self, + *, + base_vws_url: str = "https://vws.vuforia.com", + base_vwq_url: str = "https://cloudreco.vuforia.com", + cloud_query_failure_response: CloudQueryFailureResponse | None = None, + duplicate_match_checker: ImageMatcher = _STRUCTURAL_SIMILARITY_MATCHER, + query_match_checker: ImageMatcher = _STRUCTURAL_SIMILARITY_MATCHER, + processing_time_seconds: float = 2.0, + model_target_generation_failure: ( + ModelTargetGenerationFailure | None + ) = None, + model_target_generation_warning: ( + ModelTargetGenerationWarning | None + ) = None, + target_tracking_rater: TargetTrackingRater = _BRISQUE_TRACKING_RATER, + real_http: bool = False, + response_delay_seconds: float = 0.0, + sleep_fn: Callable[[float], None] = time.sleep, + vumark_generation_failure: VuMarkGenerationFailure | None = None, + ) -> None: + """Route requests to Vuforia's Web Service APIs to fakes of those + APIs. + + Works with both ``requests`` and ``httpx``. + + Args: + real_http: Whether or not to forward requests to the real + server if they are not handled by the mock. + See + https://requests-mock.readthedocs.io/en/latest/mocker.html#real-http-requests. + processing_time_seconds: The number of seconds to process each + image for. + In the real Vuforia Web Services, this is not deterministic. + model_target_generation_failure: A failure to return after every + Model Target dataset finishes processing. By default, Model + Target datasets finish successfully. + model_target_generation_warning: A warning to return after every + Model Target dataset finishes processing. By default, Model + Target datasets finish without warnings. This cannot be + combined with ``model_target_generation_failure``. + base_vwq_url: The base URL for the VWQ API. + base_vws_url: The base URL for the VWS API. + cloud_query_failure_response: A response to return for every Cloud + Query request, bypassing normal request validation and image + matching. By default, Cloud Query requests are handled + normally. + vumark_generation_failure: A failure to return for every VuMark + generation request, bypassing normal request validation and + instance generation. By default, VuMark generation requests + are handled normally. + query_match_checker: A callable which takes two image values and + returns whether they will match in a query request. + duplicate_match_checker: A callable which takes two image values + and returns whether they are duplicates. + target_tracking_rater: A callable for rating targets for tracking. + response_delay_seconds: The number of seconds to delay each + response by. This can be used to test timeout handling. + sleep_fn: The function to use for sleeping during response + delays. Defaults to ``time.sleep``. Inject a custom + function to control virtual time in tests without + monkey-patching. + + Raises: + MissingSchemeError: There is no scheme in a given URL. + ValueError: Both a Model Target generation failure and warning are + configured. + """ + super().__init__() + if ( + model_target_generation_failure is not None + and model_target_generation_warning is not None + ): + msg = ( + "Model Target generation failure and warning configurations " + "are mutually exclusive" + ) + raise ValueError(msg) + self._real_http = real_http + self._response_delay_seconds = response_delay_seconds + self._sleep_fn = sleep_fn + self._mock: RequestsMock + self._router: respx.MockRouter + self._target_manager = TargetManager() + + self._base_vws_url = base_vws_url + self._base_vwq_url = base_vwq_url + for url in (base_vwq_url, base_vws_url): + parse_result = urlparse(url=url) + if not parse_result.scheme: + raise MissingSchemeError(url=url) + + self._mock_vws_api = MockVuforiaWebServicesAPI( + target_manager=self._target_manager, + base_vws_url=base_vws_url, + processing_time_seconds=float(processing_time_seconds), + model_target_generation_failure=model_target_generation_failure, + model_target_generation_warning=model_target_generation_warning, + duplicate_match_checker=duplicate_match_checker, + target_tracking_rater=target_tracking_rater, + vumark_generation_failure=vumark_generation_failure, + ) + + self._mock_vwq_api = MockVuforiaWebQueryAPI( + target_manager=self._target_manager, + query_match_checker=query_match_checker, + failure_response=cloud_query_failure_response, + ) + + def add_cloud_database(self, cloud_database: CloudDatabase) -> None: + """Add a cloud database. + + Args: + cloud_database: The cloud database to add. + + Raises: + ValueError: One of the given cloud database keys matches a key for + an existing cloud database. + """ + self._target_manager.add_cloud_database( + cloud_database=cloud_database, + ) + + def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None: + """Add a VuMark database. + + Args: + vumark_database: The VuMark database to add. + + Raises: + ValueError: One of the given database keys matches a key for + an existing database. + """ + self._target_manager.add_vumark_database( + vumark_database=vumark_database, + ) + + @staticmethod + def _wrap_callback( + callback: _MockCallback, + delay_seconds: float, + sleep_fn: Callable[[float], None], + base_path: str, + ) -> _ResponsesCallback: + """Wrap a callback to add a response delay.""" + + def wrapped( + request: PreparedRequest, + ) -> _ResponseType: + """Handle the response delay and timeout logic.""" + # req_kwargs is added dynamically by the responses + # library onto PreparedRequest objects - it is not + # in the requests type stubs. + req_kwargs: dict[str, Any] = getattr( # pylint: disable=bad-builtin + request, + "req_kwargs", + {}, + ) + timeout: tuple[float, float] | float | int | None = req_kwargs.get( + "timeout" + ) + # requests allows timeout as a (connect, read) + # tuple. The delay simulates server response + # time, so compare against the read timeout. + match timeout: + case (_, int() | float() as read_timeout): + effective: float | None = float(read_timeout) + case int() | float(): + effective = float(timeout) + case _: + effective = None + + if effective is not None and delay_seconds > effective: + sleep_fn(effective) + raise requests.exceptions.Timeout + + match request.body: + case None: + body_bytes = b"" + case str() as raw_body: + body_bytes = raw_body.encode(encoding="utf-8") + case _: + body_bytes = request.body + + path = request.path_url + if base_path and path.startswith(base_path): + path = path[len(base_path) :] + + request_data = RequestData( + method=request.method or "", + path=path, + headers=dict(request.headers), + body=body_bytes, + ) + result = callback(request_data) + sleep_fn(delay_seconds) + return result + + return wrapped + + def __enter__(self) -> Self: + """Start an instance of a Vuforia mock. + + Returns: + ``self``. + """ + mock = RequestsMock(assert_all_requests_are_fired=False) + + for api, base_url in ( + (self._mock_vws_api, self._base_vws_url), + (self._mock_vwq_api, self._base_vwq_url), + ): + base_path = urlparse(url=base_url).path.rstrip("/") + for route in api.routes: + url_pattern = base_url.rstrip("/") + route.path_pattern + "$" + compiled_url_pattern = re.compile(pattern=url_pattern) + + for http_method in route.http_methods: + original_callback = getattr( # pylint: disable=bad-builtin + api, + route.route_name, + ) + mock.add_callback( + method=http_method, + url=compiled_url_pattern, + callback=self._wrap_callback( + callback=original_callback, + delay_seconds=self._response_delay_seconds, + sleep_fn=self._sleep_fn, + base_path=base_path, + ), + content_type=None, + ) + + if self._real_http: + all_requests_pattern = re.compile(pattern=".*") + mock.add_passthru(prefix=all_requests_pattern) + + self._mock = mock + self._mock.start() + + self._router = start_respx_router( + mock_vws_api=self._mock_vws_api, + mock_vwq_api=self._mock_vwq_api, + base_vws_url=self._base_vws_url, + base_vwq_url=self._base_vwq_url, + response_delay_seconds=self._response_delay_seconds, + sleep_fn=self._sleep_fn, + real_http=self._real_http, + ) + + return self + + def __exit__(self, *exc: object) -> Literal[False]: + """Stop the Vuforia mock. + + Returns: + False + """ + # __exit__ needs this to be passed in but vulture thinks that it is + # unused, so we "use" it here. + del exc + + self._mock.stop() + self._router.stop() + return False diff --git a/src/mock_vws/image_matchers.py b/src/mock_vws/image_matchers.py new file mode 100644 index 000000000..aa4b00b99 --- /dev/null +++ b/src/mock_vws/image_matchers.py @@ -0,0 +1,94 @@ +"""Matchers for query and duplicate requests.""" + +import io +import statistics +from typing import Protocol, runtime_checkable + +import cv2 +import numpy as np +from beartype import beartype + +from mock_vws._image_opening import open_image + + +@runtime_checkable +class ImageMatcher(Protocol): + """Protocol for a matcher for query and duplicate requests.""" + + def __call__( + self, + first_image_content: bytes, + second_image_content: bytes, + ) -> bool: + """Whether one image's content matches another's closely enough. + + Args: + first_image_content: One image's content. + second_image_content: Another image's content. + """ + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + +@beartype +class ExactMatcher: + """A matcher which returns whether two images are exactly equal.""" + + def __call__( + self, + first_image_content: bytes, + second_image_content: bytes, + ) -> bool: + """Whether one image's content matches another's exactly. + + Args: + first_image_content: One image's content. + second_image_content: Another image's content. + """ + return bool(first_image_content == second_image_content) + + +@beartype +class StructuralSimilarityMatcher: + """ + A matcher which returns whether two images are similar using + SSIM. + """ + + def __call__( + self, + first_image_content: bytes, + second_image_content: bytes, + ) -> bool: + """Whether one image's content matches another's using a SSIM. + + Args: + first_image_content: One image's content. + second_image_content: Another image's content. + """ + first_image_file = io.BytesIO(initial_bytes=first_image_content) + second_image_file = io.BytesIO(initial_bytes=second_image_content) + with ( + open_image(fp=first_image_file) as first_image, + open_image(fp=second_image_file) as second_image, + ): + # Images must be the same size, and they must be larger than the + # default SSIM window size of 11x11. + target_size = (256, 256) + first_image_array = np.asarray( + a=first_image.resize(size=target_size).convert(mode="RGB"), + ) + second_image_array = np.asarray( + a=second_image.resize(size=target_size).convert(mode="RGB"), + ) + + quality_ssim = cv2.quality.QualitySSIM.create( + ref=first_image_array, + ) + channel_scores = quality_ssim.compute(cmp=second_image_array) + ssim_score = statistics.fmean(data=channel_scores[:3]) + + # The old normalized > 7 threshold is equivalent to a raw SSIM > 0.4. + minimum_acceptable_ssim_score = 0.4 + return ssim_score > minimum_acceptable_ssim_score diff --git a/src/mock_vws/model_target.py b/src/mock_vws/model_target.py new file mode 100644 index 000000000..98e796695 --- /dev/null +++ b/src/mock_vws/model_target.py @@ -0,0 +1,204 @@ +"""Model Target dataset objects.""" + +import copy +import datetime +import uuid +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Self, TypedDict +from zoneinfo import ZoneInfo + +from beartype import beartype + + +class ModelTargetDatasetDict(TypedDict): + """A dictionary type which represents a Model Target dataset.""" + + request_body: dict[str, Any] + dataset_type_name: str + processing_time_seconds: float + generation_failure_message: str | None + generation_warning: dict[str, Any] | None + uuid: str + created_at: str + + +@beartype +class ModelTargetDatasetType(StrEnum): + """The kind of Model Target dataset.""" + + STANDARD = "standard" + ADVANCED = "advanced" + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetGenerationFailure: + """A configured Model Target dataset generation failure. + + Args: + message: The failure message included in the dataset status response. + """ + + message: str = "Model Target dataset generation failed" + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetGenerationWarning: + """A configured Model Target dataset generation warning. + + Args: + message: The top-level warning message included in the dataset status + response. + details: The warning details included in the dataset status response. + """ + + message: str = "Warning after creating dataset" + details: list[dict[str, Any]] = field( + default_factory=lambda: [ + { + "code": "LOW_RECOGNITION_QUALITY", + "message": ( + "The processed model appears to have substandard " + "recognition quality." + ), + }, + ], + ) + + +@beartype +def _now() -> datetime.datetime: + """Return the current time in UTC.""" + return datetime.datetime.now(tz=ZoneInfo(key="UTC")) + + +@beartype +def _format_datetime(value: datetime.datetime) -> str: + """Format a timestamp like the Model Target Web API.""" + return value.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetDataset: + """A Model Target dataset generation request. + + Args: + request_body: The JSON request body used to start dataset creation. + dataset_type: Whether this is a standard or advanced dataset. + processing_time_seconds: The number of seconds before the generated + dataset becomes available. + uuid_: The dataset UUID. + created_at: When the dataset creation was requested. + generation_failure: A failure to return when processing completes. + generation_warning: A warning to return when processing completes. + """ + + request_body: dict[str, Any] = field(hash=False) + dataset_type: ModelTargetDatasetType + processing_time_seconds: float = field(hash=False) + generation_failure: ModelTargetGenerationFailure | None = field(hash=False) + generation_warning: ModelTargetGenerationWarning | None = field(hash=False) + uuid_: str = field(default_factory=lambda: uuid.uuid4().hex) + created_at: datetime.datetime = field(default_factory=_now) + + @classmethod + def from_dict(cls, dataset_dict: ModelTargetDatasetDict) -> Self: + """Load a dataset from a dictionary.""" + generation_failure_message = dataset_dict["generation_failure_message"] + if generation_failure_message is None: + generation_failure = None + else: + generation_failure = ModelTargetGenerationFailure( + message=generation_failure_message, + ) + + generation_warning_dict = dataset_dict["generation_warning"] + if generation_warning_dict is None: + generation_warning = None + else: + generation_warning = ModelTargetGenerationWarning( + message=generation_warning_dict["message"], + details=generation_warning_dict["details"], + ) + + dataset_type_name = dataset_dict["dataset_type_name"] + return cls( + request_body=dataset_dict["request_body"], + dataset_type=ModelTargetDatasetType[dataset_type_name], + processing_time_seconds=dataset_dict["processing_time_seconds"], + generation_failure=generation_failure, + generation_warning=generation_warning, + uuid_=dataset_dict["uuid"], + created_at=datetime.datetime.fromisoformat( + dataset_dict["created_at"], + ), + ) + + def to_dict(self) -> ModelTargetDatasetDict: + """Dump a dataset to a dictionary which can be loaded as JSON.""" + generation_failure_message: str | None = None + if self.generation_failure is not None: + generation_failure_message = self.generation_failure.message + + generation_warning: dict[str, Any] | None = None + if self.generation_warning is not None: + generation_warning = { + "message": self.generation_warning.message, + "details": copy.deepcopy(x=self.generation_warning.details), + } + + return { + "request_body": copy.deepcopy(x=self.request_body), + "dataset_type_name": self.dataset_type.name, + "processing_time_seconds": self.processing_time_seconds, + "generation_failure_message": generation_failure_message, + "generation_warning": generation_warning, + "uuid": self.uuid_, + "created_at": self.created_at.isoformat(), + } + + @property + def completed_at(self) -> datetime.datetime: + """When the dataset completes processing.""" + return self.created_at + datetime.timedelta( + seconds=self.processing_time_seconds, + ) + + @property + def status(self) -> str: + """The current dataset generation status.""" + if _now() < self.completed_at: + return "processing" + if self.generation_failure is not None: + return "failed" + return "done" + + def status_body(self) -> dict[str, Any]: + """Return a status response body for this dataset.""" + status = self.status + body: dict[str, Any] = { + "status": status, + "uuid": self.uuid_, + "createdAt": _format_datetime(value=self.created_at), + } + if status == "processing": + body["eta"] = _format_datetime(value=self.completed_at) + else: + body["completedAt"] = _format_datetime(value=self.completed_at) + if status == "failed" and self.generation_failure is not None: + body["error"] = { + "code": "ERROR", + "message": self.generation_failure.message, + } + if status == "done" and self.generation_warning is not None: + body["warning"] = { + "code": "WARNING", + "message": self.generation_warning.message, + "target": self.uuid_, + "details": copy.deepcopy(x=self.generation_warning.details), + } + + return body diff --git a/src/mock_vws/reco_counts.py b/src/mock_vws/reco_counts.py new file mode 100644 index 000000000..fa04631eb --- /dev/null +++ b/src/mock_vws/reco_counts.py @@ -0,0 +1,53 @@ +"""Reco counts report objects.""" + +import datetime +import uuid +from dataclasses import dataclass, field +from zoneinfo import ZoneInfo + +from beartype import beartype + +# The mock does not count recognitions, so a generated report never has any +# rows for targets. +# Real Vuforia ends the header row with a carriage return and a line feed. +_CSV_CONTENT = "target_id,reco_count\r\n" + + +@beartype +def _now() -> datetime.datetime: + """Return the current time in UTC.""" + return datetime.datetime.now(tz=ZoneInfo(key="UTC")) + + +@beartype +@dataclass(frozen=True, kw_only=True) +class RecoCountsReport: + """A requested reco counts report. + + Args: + generation_time_seconds: The number of seconds before the report is + available to download. + uuid_: The report identifier, used in the report's download URL. + created_at: When the report was requested. + """ + + generation_time_seconds: float = field(hash=False) + uuid_: str = field(default_factory=lambda: uuid.uuid4().hex) + created_at: datetime.datetime = field(default_factory=_now) + + @property + def available_at(self) -> datetime.datetime: + """When the report becomes available to download.""" + return self.created_at + datetime.timedelta( + seconds=self.generation_time_seconds, + ) + + @property + def is_available(self) -> bool: + """Whether the report is available to download.""" + return _now() >= self.available_at + + @property + def csv_content(self) -> str: + """The content of the generated CSV report.""" + return _CSV_CONTENT diff --git a/src/mock_vws/request_rate_limits.py b/src/mock_vws/request_rate_limits.py new file mode 100644 index 000000000..16feb62a6 --- /dev/null +++ b/src/mock_vws/request_rate_limits.py @@ -0,0 +1,179 @@ +"""Per-endpoint VWS request rate limits.""" + +from dataclasses import dataclass +from enum import Enum, auto +from typing import NotRequired, Self, TypedDict + +from beartype import beartype + + +@beartype +class RateLimitedEndpoint(Enum): + """A group of VWS endpoints which share a request rate limit.""" + + GET_TARGET = auto() + GET_DUPLICATES = auto() + LIST_TARGETS = auto() + OTHER = auto() + + +@beartype +class RequestRateLimitDict(TypedDict): + """A dictionary type which represents a single request rate limit.""" + + max_requests: int + window_seconds: float + + +@beartype +class RequestRateLimitsDict(TypedDict): + """A dictionary type which represents per-endpoint rate limits.""" + + other: NotRequired[RequestRateLimitDict | None] + get_target: NotRequired[RequestRateLimitDict | None] + get_duplicates: NotRequired[RequestRateLimitDict | None] + list_targets: NotRequired[RequestRateLimitDict | None] + + +@beartype +@dataclass(eq=True, frozen=True, kw_only=True) +class RequestRateLimit: + """A maximum number of requests within a rolling time window. + + Args: + max_requests: The number of requests accepted within the window. + window_seconds: The length of the rolling window, in seconds. + """ + + max_requests: int + window_seconds: float + + def to_dict(self) -> RequestRateLimitDict: + """Dump a rate limit to a dictionary which can be loaded as + JSON. + """ + return { + "max_requests": self.max_requests, + "window_seconds": self.window_seconds, + } + + @classmethod + def from_dict(cls, limit_dict: RequestRateLimitDict) -> Self: + """Load a rate limit from a dictionary.""" + return cls( + max_requests=limit_dict["max_requests"], + window_seconds=limit_dict["window_seconds"], + ) + + +@beartype +def _limit_to_dict( + *, + limit: RequestRateLimit | None, +) -> RequestRateLimitDict | None: + """Dump a rate limit, or ``None``, to a JSON-compatible value.""" + if limit is None: + return None + return limit.to_dict() + + +@beartype +def _limit_from_dict( + *, + limit_dict: RequestRateLimitDict | None, +) -> RequestRateLimit | None: + """Load a rate limit from a dictionary, or ``None``.""" + if limit_dict is None: + return None + return RequestRateLimit.from_dict(limit_dict=limit_dict) + + +@beartype +@dataclass(eq=True, frozen=True, kw_only=True) +class RequestRateLimits: + """Request rate limits for each group of VWS endpoints. + + Each limit is tracked separately, in the same way that the real Vuforia + Web Services document separate limits per endpoint. + Endpoints without their own limit share the ``other`` limit. + A limit of ``None`` means that no limit is applied. + + Args: + other: The limit for endpoints without their own limit. + get_target: The limit for ``GET /targets/{target_id}`` requests. + get_duplicates: The limit for ``GET /duplicates/{target_id}`` + requests. + list_targets: The limit for ``GET /targets`` requests. + """ + + other: RequestRateLimit | None = None + get_target: RequestRateLimit | None = None + get_duplicates: RequestRateLimit | None = None + list_targets: RequestRateLimit | None = None + + def for_endpoint( + self, + *, + endpoint: RateLimitedEndpoint, + ) -> tuple[RateLimitedEndpoint, RequestRateLimit] | None: + """Return the limit which applies to an endpoint. + + Args: + endpoint: The endpoint to get a limit for. + + Returns: + The endpoint group which shares the limit, and the limit itself, + or ``None`` if no limit applies. + """ + endpoint_limits = { + RateLimitedEndpoint.GET_TARGET: self.get_target, + RateLimitedEndpoint.GET_DUPLICATES: self.get_duplicates, + RateLimitedEndpoint.LIST_TARGETS: self.list_targets, + RateLimitedEndpoint.OTHER: None, + } + limit = endpoint_limits[endpoint] + if limit is not None: + return (endpoint, limit) + if self.other is not None: + return (RateLimitedEndpoint.OTHER, self.other) + return None + + def to_dict(self) -> RequestRateLimitsDict: + """Dump rate limits to a dictionary which can be loaded as + JSON. + """ + return { + "other": _limit_to_dict(limit=self.other), + "get_target": _limit_to_dict(limit=self.get_target), + "get_duplicates": _limit_to_dict(limit=self.get_duplicates), + "list_targets": _limit_to_dict(limit=self.list_targets), + } + + @classmethod + def from_dict(cls, limits_dict: RequestRateLimitsDict) -> Self: + """Load rate limits from a dictionary.""" + return cls( + other=_limit_from_dict(limit_dict=limits_dict.get("other")), + get_target=_limit_from_dict( + limit_dict=limits_dict.get("get_target"), + ), + get_duplicates=_limit_from_dict( + limit_dict=limits_dict.get("get_duplicates"), + ), + list_targets=_limit_from_dict( + limit_dict=limits_dict.get("list_targets"), + ), + ) + + +DOCUMENTED_REQUEST_RATE_LIMITS = RequestRateLimits( + other=RequestRateLimit(max_requests=15, window_seconds=1.0), + get_target=RequestRateLimit(max_requests=45, window_seconds=1.0), + get_duplicates=RequestRateLimit(max_requests=10, window_seconds=1.0), + list_targets=RequestRateLimit(max_requests=1, window_seconds=60.0), +) +"""The request rate limits documented by Vuforia. + +These limits have not been verified against the real Vuforia Web Services, +and so they are not applied by default. +""" diff --git a/src/mock_vws/resources/match_processing_response.html b/src/mock_vws/resources/match_processing_response.html deleted file mode 100644 index 468b056c4..000000000 --- a/src/mock_vws/resources/match_processing_response.html +++ /dev/null @@ -1,110 +0,0 @@ - - - -Error 500 Server Error - -

HTTP ERROR 500

-

Problem accessing /v1/query. Reason: -

    Server Error

Caused by:

org.jboss.resteasy.spi.UnhandledException: com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input at [Source: (byte[])""; line: 1, column: 0]
-	at org.jboss.resteasy.core.ExceptionHandler.handleApplicationException(ExceptionHandler.java:76)
-	at org.jboss.resteasy.core.ExceptionHandler.handleException(ExceptionHandler.java:212)
-	at org.jboss.resteasy.core.SynchronousDispatcher.writeException(SynchronousDispatcher.java:168)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:411)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:202)
-	at org.jboss.resteasy.plugins.server.servlet.ServletContainerDispatcher.service(ServletContainerDispatcher.java:221)
-	at org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:56)
-	at org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:51)
-	at javax.servlet.http.HttpServlet.service(HttpServlet.java:790)
-	at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:808)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1669)
-	at com.kooaba.queryservice.auth.KWSAuthFilter.doFilter(KWSAuthFilter.java:171)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652)
-	at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:585)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
-	at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:577)
-	at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:223)
-	at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1127)
-	at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:515)
-	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:185)
-	at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1061)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
-	at org.eclipse.jetty.server.handler.ContextHandlerCollection.handle(ContextHandlerCollection.java:215)
-	at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:110)
-	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:97)
-	at org.eclipse.jetty.server.Server.handle(Server.java:497)
-	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:310)
-	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:257)
-	at org.eclipse.jetty.io.AbstractConnection$2.run(AbstractConnection.java:540)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:635)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:555)
-	at java.lang.Thread.run(Thread.java:748) Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input at [Source: (byte[])""; line: 1, column: 0]
-	at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
-	at com.fasterxml.jackson.databind.ObjectMapper._initForReading(ObjectMapper.java:4133)
-	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3988)
-	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3094)
-	at com.kooaba.queryservice.domain.WebResult.setTargetData(WebResult.java:44)
-	at com.kooaba.queryservice.domain.WebQueryResultProcessor.formatResult(WebQueryResultProcessor.java:81)
-	at com.kooaba.queryservice.services.QueryResourceVuforia.queryCommon(QueryResourceVuforia.java:230)
-	at com.kooaba.queryservice.services.QueryResourceVuforia.limitedConcurrencyQuery(QueryResourceVuforia.java:77)
-	at com.kooaba.queryservice.services.QueryResourceCloudRecoWebAPI.query(QueryResourceCloudRecoWebAPI.java:55)
-	at sun.reflect.GeneratedMethodAccessor99.invoke(Unknown Source)
-	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
-	at java.lang.reflect.Method.invoke(Method.java:606)
-	at org.jboss.resteasy.core.MethodInjectorImpl.invoke(MethodInjectorImpl.java:139)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTarget(ResourceMethodInvoker.java:295)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:249)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:236)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:395)
-	... 28
- more
-
-

Caused by:

com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input at [Source: (byte[])""; line: 1, column: 0]
-	at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
-	at com.fasterxml.jackson.databind.ObjectMapper._initForReading(ObjectMapper.java:4133)
-	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3988)	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3094)
-	at com.kooaba.queryservice.domain.WebResult.setTargetData(WebResult.java:44)
-	at com.kooaba.queryservice.domain.WebQueryResultProcessor.formatResult(WebQueryResultProcessor.java:81)
-	at com.kooaba.queryservice.services.QueryResourceVuforia.queryCommon(QueryResourceVuforia.java:230)
-	at com.kooaba.queryservice.services.QueryResourceVuforia.limitedConcurrencyQuery(QueryResourceVuforia.java:77)
-	at com.kooaba.queryservice.services.QueryResourceCloudRecoWebAPI.query(QueryResourceCloudRecoWebAPI.java:55)
-	at sun.reflect.GeneratedMethodAccessor99.invoke(Unknown Source)
-	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
-	at java.lang.reflect.Method.invoke(Method.java:606)
-	at org.jboss.resteasy.core.MethodInjectorImpl.invoke(MethodInjectorImpl.java:139)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTarget(ResourceMethodInvoker.java:295)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:249)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:236)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:395)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:202)
-	at org.jboss.resteasy.plugins.server.servlet.ServletContainerDispatcher.service(ServletContainerDispatcher.java:221)
-	at org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:56)
-	at org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:51)
-	at javax.servlet.http.HttpServlet.service(HttpServlet.java:790)
-	at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:808)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1669)
-	at com.kooaba.queryservice.auth.KWSAuthFilter.doFilter(KWSAuthFilter.java:171)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652)
-	at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:585)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
-	at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:577)
-	at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:223)
-	at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1127)
-	at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:515)
-	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:185)
-	at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1061)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
-	at org.eclipse.jetty.server.handler.ContextHandlerCollection.handle(ContextHandlerCollection.java:215)
-	at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:110)
-	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:97)
-	at org.eclipse.jetty.server.Server.handle(Server.java:497)
-	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:310)
-	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:257)
-	at org.eclipse.jetty.io.AbstractConnection$2.run(AbstractConnection.java:540)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:635)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:555)
-	at java.lang.Thread.run(Thread.java:748)
-
-
Powered by Jetty://
- - - diff --git a/src/mock_vws/resources/oops_error_occurred_response.html b/src/mock_vws/resources/oops_error_occurred_response.html deleted file mode 100644 index e72b8fc60..000000000 --- a/src/mock_vws/resources/oops_error_occurred_response.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - Error - - - -

Oops, an error occurred

- -

- This exception has been logged with id 7db293le3. -

- - - diff --git a/src/mock_vws/states.py b/src/mock_vws/states.py index f6025198a..9c2fed652 100644 --- a/src/mock_vws/states.py +++ b/src/mock_vws/states.py @@ -1,25 +1,20 @@ -""" -Vuforia database states. -""" +"""Vuforia database states.""" -from enum import Enum, auto +from enum import StrEnum, auto, unique +from beartype import beartype -class States(Enum): - """ - Constants representing various web service states. - """ + +@beartype +@unique +class States(StrEnum): + """Constants representing various web service states.""" WORKING = auto() + PROJECT_SUSPENDED = auto() + # A project is inactive if the license key has been deleted. PROJECT_INACTIVE = auto() - def __repr__(self) -> str: - """ - Return a representation which does not include the generated number. - """ - return '<{class_name}.{state_name}>'.format( - class_name=self.__class__.__name__, - state_name=self.name, - ) + PROJECT_HAS_NO_API_ACCESS = auto() diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index 24feec48e..556943a8a 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -1,180 +1,298 @@ -""" -A fake implementation of a target for the Vuforia Web Services API. -""" +"""A fake implementation of a target for the Vuforia Web Services API.""" +import base64 import datetime import io -import random import statistics import uuid -from typing import Optional, Union +from dataclasses import dataclass, field +from typing import NotRequired, Self, TypedDict +from zoneinfo import ZoneInfo -from backports.zoneinfo import ZoneInfo -from PIL import Image, ImageStat +from beartype import BeartypeConf, beartype +from PIL import ImageStat from mock_vws._constants import TargetStatuses +from mock_vws._image_opening import open_image +from mock_vws.target_raters import ( + HardcodedTargetTrackingRater, + TargetTrackingRater, +) -class Target: # pylint: disable=too-many-instance-attributes - """ - A Vuforia Target as managed in - https://developer.vuforia.com/target-manager. - """ +class VuMarkTargetDict(TypedDict): + """A dictionary type which represents a VuMark target.""" + + target_id: str + name: str + processing_time_seconds: float + last_modified_date: str + upload_date: str + + +class ImageTargetDict(TypedDict): + """A dictionary type which represents an image target.""" name: str + width: float + image_base64: str + active_flag: bool + processing_time_seconds: float + application_metadata: str | None target_id: str + last_modified_date: str + delete_date_optional: str | None + upload_date: str + tracking_rating: int + current_month_recos: NotRequired[int] + previous_month_recos: NotRequired[int] + total_recos: NotRequired[int] + reco_rating: NotRequired[str] + + +@beartype +def _random_hex() -> str: + """Return a random hex value.""" + return uuid.uuid4().hex + + +@beartype +def _time_now() -> datetime.datetime: + """Return the current time in the GMT time zone.""" + gmt = ZoneInfo(key="GMT") + return datetime.datetime.now(tz=gmt) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, eq=True, kw_only=True) +class ImageTarget: + """A Vuforia image target as managed in + https://developer.vuforia.com/target-manager. + """ + active_flag: bool + application_metadata: str | None + image_value: bytes + name: str + processing_time_seconds: float width: float - upload_date: datetime.datetime - last_modified_date: datetime.datetime - processed_tracking_rating: int - image: io.BytesIO - reco_rating: str - application_metadata: str - delete_date: Optional[datetime.datetime] - - def __init__( # pylint: disable=too-many-arguments - self, - name: str, - active_flag: bool, - width: float, - image: io.BytesIO, - processing_time_seconds: Union[int, float], - application_metadata: str, - ) -> None: - """ - Args: - name: The name of the target. - active_flag: Whether or not the target is active for query. - width: The width of the image in scene unit. - image: The image associated with the target. - processing_time_seconds: The number of seconds to process each - image for. In the real Vuforia Web Services, this is not - deterministic. - application_metadata: The base64 encoded application metadata - associated with the target. - - Attributes: - name (str): The name of the target. - target_id (str): The unique ID of the target. - active_flag (bool): Whether or not the target is active for query. - width (float): The width of the image in scene unit. - upload_date (datetime.datetime): The time that the target was - created. - last_modified_date (datetime.datetime): The time that the target - was last modified. - processed_tracking_rating (int): The tracking rating of the target - once it has been processed. - image (io.BytesIO): The image data associated with the target. - reco_rating (str): An empty string ("for now" according to - Vuforia's documentation). - application_metadata (str): The base64 encoded application metadata - associated with the target. - delete_date (typing.Optional[datetime.datetime]): The time that the - target was deleted. - """ - self.name = name - self.target_id = uuid.uuid4().hex - self.active_flag = active_flag - self.width = width - gmt = ZoneInfo('GMT') - now = datetime.datetime.now(tz=gmt) - self.upload_date: datetime.datetime = now - self.last_modified_date = self.upload_date - self.processed_tracking_rating = random.randint(0, 5) - self.image = image - self.reco_rating = '' - self._processing_time_seconds = processing_time_seconds - self.application_metadata = application_metadata - self.delete_date: Optional[datetime.datetime] = None - - def __repr__(self) -> str: - """ - Return a representation which includes the target ID. - """ - class_name = self.__class__.__name__ - return f'<{class_name}: {self.target_id}>' - - def delete(self) -> None: - """ - Mark the target as deleted. - """ - gmt = ZoneInfo('GMT') - now = datetime.datetime.now(tz=gmt) - self.delete_date = now + target_tracking_rater: TargetTrackingRater = field(compare=False) + current_month_recos: int = 0 + delete_date: datetime.datetime | None = None + last_modified_date: datetime.datetime = field(default_factory=_time_now) + previous_month_recos: int = 0 + reco_rating: str = "" + target_id: str = field(default_factory=_random_hex) + total_recos: int = 0 + upload_date: datetime.datetime = field(default_factory=_time_now) @property def _post_processing_status(self) -> TargetStatuses: - """ - Return the status of the target, or what it will be when processing is - finished. + """Return the status of the target, or what it will be when + processing + is finished. The status depends on the standard deviation of the color bands. - How VWS determines this is unknown, but it relates to how suitable the - target is for detection. + How VWS determines this is unknown, but it relates to how + suitable the target is for detection. """ - image = Image.open(self.image) - image_stat = ImageStat.Stat(image) + image_file = io.BytesIO(initial_bytes=self.image_value) + with open_image(fp=image_file) as image: + image_stat = ImageStat.Stat(image_or_list=image) + average_std_dev = statistics.mean(data=image_stat.stddev) - average_std_dev = statistics.mean(image_stat.stddev) + success_threshold = 5 - if average_std_dev > 5: + if average_std_dev > success_threshold: return TargetStatuses.SUCCESS return TargetStatuses.FAILED @property def status(self) -> str: - """ - Return the status of the target. + """Return the status of the target. For now this waits half a second (arbitrary) before changing the status from 'processing' to 'failed' or 'success'. The status depends on the standard deviation of the color bands. - How VWS determines this is unknown, but it relates to how suitable the - target is for detection. + How VWS determines this is unknown, but it relates to how + suitable the target is for detection. """ processing_time = datetime.timedelta( - seconds=self._processing_time_seconds, + seconds=float(self.processing_time_seconds), ) - gmt = ZoneInfo('GMT') - now = datetime.datetime.now(tz=gmt) + timezone = self.upload_date.tzinfo + now = datetime.datetime.now(tz=timezone) time_since_change = now - self.last_modified_date if time_since_change <= processing_time: - return str(TargetStatuses.PROCESSING.value) + return TargetStatuses.PROCESSING.value + + return self._post_processing_status.value - return str(self._post_processing_status.value) + @property + def _post_processing_target_rating(self) -> int: + """The rating of the target after processing.""" + return self.target_tracking_rater(image_content=self.image_value) @property def tracking_rating(self) -> int: - """ - Return the tracking rating of the target recognition image. - - In this implementation that is just a random integer between 0 and 5 - if the target status is 'success'. - The rating is 0 if the target status is 'failed'. - The rating is -1 for a short time while the target is being processed. - The real VWS seems to give -1 for a short time while processing, then - the real rating, even while it is still processing. - """ + """Return the tracking rating of the target recognition image.""" pre_rating_time = datetime.timedelta( # That this is half of the total processing time is unrealistic. # In VWS it is not a constant percentage. - seconds=self._processing_time_seconds - / 2, + seconds=float(self.processing_time_seconds) / 2, ) - gmt = ZoneInfo('GMT') - now = datetime.datetime.now(tz=gmt) + timezone = self.upload_date.tzinfo + now = datetime.datetime.now(tz=timezone) time_since_upload = now - self.upload_date + # The real VWS seems to give -1 for a short time while processing, then + # the real rating, even while it is still processing. if time_since_upload <= pre_rating_time: return -1 - if self._post_processing_status == TargetStatuses.SUCCESS: - return self.processed_tracking_rating + return self._post_processing_target_rating + + @classmethod + def from_dict(cls, target_dict: ImageTargetDict) -> Self: + """Load a target from a dictionary.""" + timezone = ZoneInfo(key="GMT") + name = target_dict["name"] + active_flag = target_dict["active_flag"] + width = target_dict["width"] + image_base64 = target_dict["image_base64"] + image_value = base64.b64decode(s=image_base64) + processing_time_seconds = target_dict["processing_time_seconds"] + application_metadata = target_dict["application_metadata"] + target_id = target_dict["target_id"] + delete_date_optional = target_dict["delete_date_optional"] + if delete_date_optional is None: + delete_date = None + else: + delete_date = datetime.datetime.fromisoformat(delete_date_optional) + delete_date = delete_date.replace(tzinfo=timezone) + + last_modified_date = datetime.datetime.fromisoformat( + target_dict["last_modified_date"], + ).replace(tzinfo=timezone) + upload_date = datetime.datetime.fromisoformat( + target_dict["upload_date"], + ).replace(tzinfo=timezone) + + target_tracking_rater = HardcodedTargetTrackingRater( + rating=target_dict["tracking_rating"], + ) + return cls( + target_id=target_id, + name=name, + active_flag=active_flag, + width=width, + image_value=image_value, + processing_time_seconds=processing_time_seconds, + application_metadata=application_metadata, + delete_date=delete_date, + last_modified_date=last_modified_date, + upload_date=upload_date, + target_tracking_rater=target_tracking_rater, + current_month_recos=target_dict.get("current_month_recos", 0), + previous_month_recos=target_dict.get("previous_month_recos", 0), + total_recos=target_dict.get("total_recos", 0), + reco_rating=target_dict.get("reco_rating", ""), + ) + + def to_dict(self) -> ImageTargetDict: + """Dump a target to a dictionary which can be loaded as JSON.""" + delete_date: str | None = None + if self.delete_date: + delete_date = self.delete_date.isoformat() + + image_base64 = base64.encodebytes(s=self.image_value).decode() + + return { + "name": self.name, + "width": self.width, + "image_base64": image_base64, + "active_flag": self.active_flag, + "processing_time_seconds": float(self.processing_time_seconds), + "application_metadata": self.application_metadata, + "target_id": self.target_id, + "last_modified_date": self.last_modified_date.isoformat(), + "delete_date_optional": delete_date, + "upload_date": self.upload_date.isoformat(), + "tracking_rating": self.tracking_rating, + "current_month_recos": self.current_month_recos, + "previous_month_recos": self.previous_month_recos, + "total_recos": self.total_recos, + "reco_rating": self.reco_rating, + } + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, eq=True, kw_only=True) +class VuMarkTarget: + """ + A VuMark target as managed in + https://developer.vuforia.com/target-manager. + + Unlike ImageTarget, VuMark targets do not require an image — they use a + VuMark template. + """ + + name: str + processing_time_seconds: float = 0.0 + target_id: str = field(default_factory=_random_hex) + last_modified_date: datetime.datetime = field(default_factory=_time_now) + upload_date: datetime.datetime = field(default_factory=_time_now) + + @property + def status(self) -> str: + """Return the status of the target. + + VuMark targets always succeed after processing. + """ + processing_time = datetime.timedelta( + seconds=float(self.processing_time_seconds), + ) - return 0 + timezone = self.upload_date.tzinfo + now = datetime.datetime.now(tz=timezone) + time_since_change = now - self.last_modified_date + + if time_since_change <= processing_time: + return TargetStatuses.PROCESSING.value + + return TargetStatuses.SUCCESS.value + + @classmethod + def from_dict(cls, target_dict: VuMarkTargetDict) -> Self: + """Load a VuMark target from a dictionary.""" + timezone = ZoneInfo(key="GMT") + last_modified_date = datetime.datetime.fromisoformat( + target_dict["last_modified_date"], + ).replace(tzinfo=timezone) + upload_date = datetime.datetime.fromisoformat( + target_dict["upload_date"], + ).replace(tzinfo=timezone) + return cls( + target_id=target_dict["target_id"], + name=target_dict["name"], + processing_time_seconds=target_dict["processing_time_seconds"], + last_modified_date=last_modified_date, + upload_date=upload_date, + ) + + def to_dict(self) -> VuMarkTargetDict: + """Dump a VuMark target to a dictionary which can be loaded as + JSON. + """ + return { + "target_id": self.target_id, + "name": self.name, + "processing_time_seconds": float(self.processing_time_seconds), + "last_modified_date": self.last_modified_date.isoformat(), + "upload_date": self.upload_date.isoformat(), + } diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py new file mode 100644 index 000000000..365a2242c --- /dev/null +++ b/src/mock_vws/target_manager.py @@ -0,0 +1,207 @@ +"""A fake implementation of a Vuforia target manager.""" + +import time +from typing import TYPE_CHECKING + +from beartype import beartype + +from mock_vws._services_validators.request_rate_validators import ( + RequestRateLimiter, +) +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.model_target import ModelTargetDataset +from mock_vws.reco_counts import RecoCountsReport + +if TYPE_CHECKING: + from mock_vws._database_matchers import AnyDatabase + + +@beartype +class TargetManager: + """ + A target manager. + + See https://developer.vuforia.com/target-manager. + """ + + def __init__(self) -> None: + """Create a target manager with no databases.""" + self._cloud_databases: set[CloudDatabase] = set() + self._vumark_databases: set[VuMarkDatabase] = set() + self._model_target_datasets: dict[str, ModelTargetDataset] = {} + self._reco_counts_reports: dict[str, RecoCountsReport] = {} + self._request_rate_limiter = RequestRateLimiter( + time_function=time.monotonic, + ) + + @property + def request_rate_limiter(self) -> RequestRateLimiter: + """The rate limiter for databases in this target manager.""" + return self._request_rate_limiter + + @property + def cloud_databases(self) -> set[CloudDatabase]: + """All cloud databases.""" + return set(self._cloud_databases) + + @property + def vumark_databases(self) -> set[VuMarkDatabase]: + """All VuMark databases.""" + return set(self._vumark_databases) + + @property + def model_target_datasets(self) -> dict[str, ModelTargetDataset]: + """All Model Target datasets, keyed by UUID.""" + return dict(self._model_target_datasets) + + @property + def reco_counts_reports(self) -> dict[str, RecoCountsReport]: + """All reco counts reports, keyed by report identifier.""" + return dict(self._reco_counts_reports) + + def add_reco_counts_report( + self, + reco_counts_report: RecoCountsReport, + ) -> None: + """Add a reco counts report.""" + self._reco_counts_reports[reco_counts_report.uuid_] = ( + reco_counts_report + ) + + def remove_cloud_database(self, cloud_database: CloudDatabase) -> None: + """Remove a cloud database. + + Args: + cloud_database: The cloud database to remove. + + Raises: + KeyError: The cloud database is not in the target manager. + """ + self._cloud_databases = { + db for db in self._cloud_databases if db != cloud_database + } + self._request_rate_limiter.remove_database(database=cloud_database) + + def remove_vumark_database(self, vumark_database: VuMarkDatabase) -> None: + """Remove a VuMark database. + + Args: + vumark_database: The VuMark database to remove. + """ + self._vumark_databases = { + db for db in self._vumark_databases if db != vumark_database + } + + def add_model_target_dataset( + self, + model_target_dataset: ModelTargetDataset, + ) -> None: + """Add a Model Target dataset.""" + self._model_target_datasets[model_target_dataset.uuid_] = ( + model_target_dataset + ) + + def remove_model_target_dataset(self, dataset_uuid: str) -> None: + """Remove a Model Target dataset.""" + del self._model_target_datasets[dataset_uuid] + + def add_cloud_database(self, cloud_database: CloudDatabase) -> None: + """Add a cloud database. + + Args: + cloud_database: The cloud database to add. + + Raises: + ValueError: One of the given cloud database keys matches a key for + an existing cloud database. + """ + message_fmt = ( + "All {key_name}s must be unique. " + 'There is already a database with the {key_name} "{value}".' + ) + all_databases: list[AnyDatabase] = [ + *self._cloud_databases, + *self._vumark_databases, + ] + for existing_db in all_databases: + for existing, new, key_name in ( + ( + existing_db.server_access_key, + cloud_database.server_access_key, + "server access key", + ), + ( + existing_db.server_secret_key, + cloud_database.server_secret_key, + "server secret key", + ), + ( + existing_db.database_name, + cloud_database.database_name, + "name", + ), + ): + if existing == new: + message = message_fmt.format(key_name=key_name, value=new) + raise ValueError(message) + + for existing_cloud_db in self._cloud_databases: + for existing, new, key_name in ( + ( + existing_cloud_db.client_access_key, + cloud_database.client_access_key, + "client access key", + ), + ( + existing_cloud_db.client_secret_key, + cloud_database.client_secret_key, + "client secret key", + ), + ): + if existing == new: + message = message_fmt.format(key_name=key_name, value=new) + raise ValueError(message) + + self._cloud_databases = {*self._cloud_databases, cloud_database} + + def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None: + """Add a VuMark database. + + Args: + vumark_database: The VuMark database to add. + + Raises: + ValueError: One of the given database keys matches a key for + an existing database. + """ + message_fmt = ( + "All {key_name}s must be unique. " + 'There is already a database with the {key_name} "{value}".' + ) + all_databases: list[AnyDatabase] = [ + *self._cloud_databases, + *self._vumark_databases, + ] + for existing_db in all_databases: + for existing, new, key_name in ( + ( + existing_db.server_access_key, + vumark_database.server_access_key, + "server access key", + ), + ( + existing_db.server_secret_key, + vumark_database.server_secret_key, + "server secret key", + ), + ( + existing_db.database_name, + vumark_database.database_name, + "name", + ), + ): + if existing == new: + message = message_fmt.format(key_name=key_name, value=new) + raise ValueError(message) + + self._vumark_databases = {*self._vumark_databases, vumark_database} diff --git a/src/mock_vws/target_raters.py b/src/mock_vws/target_raters.py new file mode 100644 index 000000000..1d4393915 --- /dev/null +++ b/src/mock_vws/target_raters.py @@ -0,0 +1,112 @@ +"""Raters for target quality.""" + +import functools +import io +import math +import secrets +import warnings +from typing import Protocol, runtime_checkable + +from beartype import beartype +from pyteenybrisque import score + +from mock_vws._image_opening import open_image + + +@functools.cache +@beartype +def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int: + """Get a target tracking rating based on a BRISQUE score. + + This is a rough approximation of the quality score used by Vuforia, but is + not accurate. For example, our "corrupted_image" rating is based on a + BRISQUE score of 0, but Vuforia's is 1. + + Args: + image_content: A target's image's content. + """ + image_file = io.BytesIO(initial_bytes=image_content) + with open_image(fp=image_file) as image, warnings.catch_warnings(): + # Uniform images produce a zero-variance warning and non-finite score. + warnings.simplefilter(action="ignore", category=RuntimeWarning) + try: + brisque_score = score(image=image) + except ZeroDivisionError: + # An image of a single color divides by zero rather than giving a + # non-finite score. + return 0 + + if not math.isfinite(brisque_score): + return 0 + + # BRISQUE ranges from 0 (best) to 100 (worst), while Vuforia's target + # tracking rating ranges from 0 (worst) to 5 (best). + rating = 5 - math.floor(brisque_score / 20) + return min(5, max(0, rating)) + + +@runtime_checkable +class TargetTrackingRater(Protocol): + """Protocol for a rater of target quality.""" + + def __call__(self, image_content: bytes) -> int: + """The target tracking rating. + + Args: + image_content: A target's image's content. + """ + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + +@beartype +class RandomTargetTrackingRater: + """A rater which returns a random number.""" + + def __call__(self, image_content: bytes) -> int: + """A random target tracking rating. + + Args: + image_content: A target's image's content. + """ + del image_content + return secrets.randbelow(exclusive_upper_bound=6) + + +@beartype +class HardcodedTargetTrackingRater: + """A rater which returns a hardcoded number.""" + + def __init__(self, rating: int) -> None: + """ + Args: + rating: The rating to return. + """ + self._rating = rating + + def __call__(self, image_content: bytes) -> int: + """A random target tracking rating. + + Args: + image_content: A target's image's content. + """ + del image_content + return self._rating + + +@beartype +class BrisqueTargetTrackingRater: + """A rater which returns a rating based on a BRISQUE score.""" + + def __call__(self, image_content: bytes) -> int: + """A rating based on a BRISQUE score. + + This is a rough approximation of the quality score used by Vuforia, but + is not accurate. For example, our "corrupted_image" fixture is rated as + -2 by Vuforia, but is rated as 0 by this function. + + Args: + image_content: A target's image's content. + """ + return _get_brisque_target_tracking_rating(image_content=image_content) diff --git a/src/mock_vws/vumark.py b/src/mock_vws/vumark.py new file mode 100644 index 000000000..acf72a04a --- /dev/null +++ b/src/mock_vws/vumark.py @@ -0,0 +1,23 @@ +"""Public configuration types for the VuMark Generation API.""" + +from enum import StrEnum, unique +from http import HTTPStatus + +from beartype import beartype + + +@beartype +@unique +class VuMarkGenerationFailure(StrEnum): + """A configured failure returned by the VuMark Generation API mock.""" + + QUOTA_EXCEEDED = "QuotaExceeded" + LICENSE_CHECK_FAILED = "LicenseCheckFailed" + AUTHORIZATION_FAILED = "AuthorizationFailed" + + @property + def status_code(self) -> HTTPStatus: + """Return the HTTP status documented for this failure.""" + if self is VuMarkGenerationFailure.AUTHORIZATION_FAILED: + return HTTPStatus.UNAUTHORIZED + return HTTPStatus.FORBIDDEN diff --git a/tests/backend_harness.py b/tests/backend_harness.py new file mode 100644 index 000000000..17841567a --- /dev/null +++ b/tests/backend_harness.py @@ -0,0 +1,159 @@ +"""Run one test suite against several interchangeable backends. + +A "backend" is one way of running the system which the tests exercise. +A suite might run against a real remote service, an in-memory fake of +that service, and the same fake behind an HTTP server, and assert the +same things about each. That is how a fake is kept honest. + +Nothing in this module knows about Vuforia, and nothing in it may import +from ``mock_vws``. Backends are identified by members of any +:class:`~enum.Enum`: the member name gives the command line option which +deselects it, and the member value gives the ID which ``pytest`` shows +for it. + +This module is a candidate for extraction as a ``pytest`` plugin. Keep +it free of anything which is specific to this project so that extracting +it stays a move rather than a rewrite. +""" + +import contextlib +import functools +from collections.abc import Callable, Generator, Iterable, Sequence +from enum import Enum + +import pytest + +# ``pytest.fixture`` returns one of these, but ``pytest`` does not export +# the type. +# See https://github.com/pytest-dev/pytest/issues/14853. +from _pytest.fixtures import ( # pylint: disable=import-private-name + FixtureFunctionDefinition, +) +from beartype import beartype + + +@beartype +def _skip_option(*, backend: Enum) -> str: + """The command line option which deselects a backend. + + Args: + backend: The backend to give the option for. + + Returns: + The name of the option which deselects the given backend. + """ + return f"--skip-{backend.name.lower()}" + + +@beartype +def add_skip_options( + *, + parser: pytest.Parser, + backends: Iterable[Enum], +) -> None: + """Add an option which deselects each backend. + + Call this from a ``pytest_addoption`` hook. Tests which use a + deselected backend are skipped rather than deselected, so that a run + which skips a backend still reports the tests which would have used + it. + + Args: + parser: The parser to add options to. + backends: The backends to add options for. + """ + for backend in backends: + parser.addoption( + _skip_option(backend=backend), + action="store_true", + default=False, + help=f"Skip tests for {backend.value}", + ) + + +@beartype +def _backend_ids(*, backends: Iterable[Enum]) -> list[str]: + """The IDs which ``pytest`` shows for a set of backends. + + Args: + backends: The backends to give IDs for. + + Returns: + The ID to show for each given backend. + """ + return [str(object=backend.value) for backend in backends] + + +@beartype +@contextlib.contextmanager +def _running_backend( + *, + backend: Enum, + config: pytest.Config, + setup: Callable[[], Generator[None]], +) -> Generator[None]: + """Set a backend up for the duration of a test. + + Args: + backend: The backend to run the test against. + config: The configuration to look for skip options in. + setup: A generator function which sets the backend up, yields + once while the test runs, and then tears it down. Bind any + arguments it needs with :func:`functools.partial` before + passing it in. + + Yields: + ``None``, once the backend is set up. + """ + if config.getoption(name=_skip_option(backend=backend)): + pytest.skip() + + with contextlib.contextmanager(func=setup)(): + yield + + +@beartype +def backend_fixture( + *, + name: str, + backends: Sequence[Enum], + setup_for: Callable[..., Generator[None]], +) -> FixtureFunctionDefinition: + """Make a fixture which runs each test once per backend. + + Args: + name: The name which tests use to request the fixture. + backends: The backends to run each test against. + setup_for: A generator function which is called with the keyword + arguments ``backend`` and ``request``. It sets that backend + up, yields once while the test runs, and then tears it down. + Anything else it needs comes from + :meth:`~pytest.FixtureRequest.getfixturevalue`, because a + fixture made here requests no fixtures but ``request``. + + Returns: + A fixture which yields the backend which the test is running + against. + """ + + @pytest.fixture( + name=name, + params=backends, + ids=_backend_ids(backends=backends), + ) + def _fixture(*, request: pytest.FixtureRequest) -> Generator[Enum]: + """Run a test against one backend. + + Yields: + The backend which the test is running against. + """ + backend: Enum = request.param + setup = functools.partial(setup_for, backend=backend, request=request) + with _running_backend( + backend=backend, + config=request.config, + setup=setup, + ): + yield backend + + return _fixture diff --git a/tests/conftest.py b/tests/conftest.py index 54f383b00..93d76f449 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,4 @@ -""" -Configuration, plugins and fixtures for `pytest`. -""" +"""Configuration, plugins and fixtures for `pytest`.""" import base64 import binascii @@ -8,123 +6,244 @@ import uuid import pytest -from _pytest.fixtures import SubRequest +from beartype import beartype from vws import VWS, CloudRecoService +from vws.reports import TargetStatuses -from mock_vws.database import VuforiaDatabase -from tests.mock_vws.utils import Endpoint +from mock_vws.database import CloudDatabase +from tests.mock_vws.utils import Endpoint, ModelTargetEndpoint +from tests.mock_vws.utils.retries import RETRY_ON_TRANSIENT_VWS_FAILURE +# The number of targets to add before giving up on getting one which +# processes with a 'success' status. +_TARGET_SUCCESS_ATTEMPTS = 3 + +# `credentials` must be listed before modules that import from it. +# If listed later, those imports happen before pytest can register it for +# assertion rewriting, causing a PytestAssertRewriteWarning. pytest_plugins = [ - 'tests.mock_vws.fixtures.prepared_requests', - 'tests.mock_vws.fixtures.credentials', - 'tests.mock_vws.fixtures.vuforia_backends', + "tests.mock_vws.fixtures.credentials", + "tests.mock_vws.fixtures.prepared_requests", + "tests.mock_vws.fixtures.vuforia_backends", + # ``model_target_prepared_requests`` imports from + # ``vuforia_backends``, so it must be listed after it. + "tests.mock_vws.fixtures.model_target_prepared_requests", ] -@pytest.fixture(name='vws_client') -def fixture_vws_client(vuforia_database: VuforiaDatabase) -> VWS: - """ - A VWS client for an active VWS database. - """ +@pytest.fixture(name="vws_client") +def fixture_vws_client(*, vuforia_database: CloudDatabase) -> VWS: + """A VWS client for an active VWS database.""" return VWS( server_access_key=vuforia_database.server_access_key, server_secret_key=vuforia_database.server_secret_key, ) -@pytest.fixture() -def cloud_reco_client(vuforia_database: VuforiaDatabase) -> CloudRecoService: - """ - A query client for an active VWS database. - """ +@pytest.fixture +def cloud_reco_client(*, vuforia_database: CloudDatabase) -> CloudRecoService: + """A query client for an active VWS database.""" return CloudRecoService( client_access_key=vuforia_database.client_access_key, client_secret_key=vuforia_database.client_secret_key, ) -@pytest.fixture(name='inactive_vws_client') -def fixture_inactive_vws_client(inactive_database: VuforiaDatabase) -> VWS: - """ - A client for an inactive VWS database. - """ +@pytest.fixture(name="inactive_vws_client") +def fixture_inactive_vws_client( + *, + inactive_cloud_database: CloudDatabase, +) -> VWS: + """A client for an inactive VWS database.""" return VWS( - server_access_key=inactive_database.server_access_key, - server_secret_key=inactive_database.server_secret_key, + server_access_key=inactive_cloud_database.server_access_key, + server_secret_key=inactive_cloud_database.server_secret_key, ) -@pytest.fixture() -def target_id( - image_file_success_state_low_rating: io.BytesIO, - vws_client: VWS, -) -> str: - """ - Return the target ID of a target in the database. +@pytest.fixture +def inactive_cloud_reco_client( + *, + inactive_cloud_database: CloudDatabase, +) -> CloudRecoService: + """A query client for an inactive VWS database.""" + return CloudRecoService( + client_access_key=inactive_cloud_database.client_access_key, + client_secret_key=inactive_cloud_database.client_secret_key, + ) + - The target is one which will have a 'success' status when processed. +@beartype +@RETRY_ON_TRANSIENT_VWS_FAILURE +def _add_target(*, vws_client: VWS, image: io.BytesIO) -> str: + """Add a target, which is then in the processing state. + + We retry on transient failures here because pytest-retry does not + retry on exceptions raised in fixtures. + + See + https://github.com/str0zzapreti/pytest-retry/issues/33. + + Returns: + The ID of the added target. """ return vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=image_file_success_state_low_rating, + image=image, active_flag=True, application_metadata=None, ) +@beartype +@RETRY_ON_TRANSIENT_VWS_FAILURE +def _add_target_which_processed_successfully( + *, + vws_client: VWS, + image: io.BytesIO, +) -> str: + """Add a target which finishes processing with a 'success' status. + + Real Vuforia sometimes rates the given image badly enough to give the + target a 'failed' status, so we delete such a target and add another + one. + + Returns: + The ID of a target with a 'success' status. + """ + for _ in range(_TARGET_SUCCESS_ATTEMPTS): + target_id_ = _add_target(vws_client=vws_client, image=image) + vws_client.wait_for_target_processed(target_id=target_id_) + target_details = vws_client.get_target_record(target_id=target_id_) + if target_details.status == TargetStatuses.SUCCESS: + return target_id_ + # We do not cover the rest of this function because in most test + # runs no target gets a 'failed' status. + vws_client.delete_target(target_id=target_id_) # pragma: no cover + + message = ( # pragma: no cover + "No target processed with a 'success' status in " + f"{_TARGET_SUCCESS_ATTEMPTS} attempts." + ) + raise AssertionError(message) # pragma: no cover + + +@pytest.fixture +def target_id(*, high_quality_image: io.BytesIO, vws_client: VWS) -> str: + """Return the target ID of a target in the database which has finished + processing with a 'success' status. + + We use ``high_quality_image`` rather than + ``image_file_success_state_low_rating``. The latter is a randomly + generated 5x5 image, and real Vuforia often gives such an image a + 'failed' status. No test which uses this fixture needs a low rating. + """ + return _add_target_which_processed_successfully( + vws_client=vws_client, + image=high_quality_image, + ) + + +@pytest.fixture +def unprocessed_target_id( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> str: + """Return the target ID of a target which was just added to the + database. + + The target is in the processing state, or it has just left it. Use + this rather than ``target_id`` for tests which do not need a + processed target, as waiting for processing is slow against real + Vuforia. + """ + return _add_target(vws_client=vws_client, image=high_quality_image) + + @pytest.fixture( params=[ - '_add_target', - '_database_summary', - '_delete_target', - '_get_duplicates', - '_get_target', - '_target_list', - '_target_summary', - '_update_target', - '_query', + "add_target", + "database_summary", + "delete_target", + "get_duplicates", + "get_target", + "target_list", + "target_summary", + "update_target", + "query", + "vumark_generate_instance", ], ) -def endpoint(request: SubRequest) -> Endpoint: +def endpoint(*, request: pytest.FixtureRequest) -> Endpoint: + """ + Return details of an endpoint for the Target API or the Query + API. """ - Return details of an endpoint for the Target API or the Query API. + endpoint_fixture: Endpoint = request.getfixturevalue(argname=request.param) + return endpoint_fixture + + +@pytest.fixture( + params=[ + "create_standard_dataset", + "create_advanced_dataset", + "standard_dataset_status", + "advanced_dataset_status", + "download_standard_dataset", + "download_advanced_dataset", + "delete_standard_dataset", + "delete_advanced_dataset", + ], +) +def model_target_endpoint( + *, + request: pytest.FixtureRequest, +) -> ModelTargetEndpoint: + """Return details of an endpoint for the Model Target Web API. + + The OAuth2 token endpoint is not included because it takes HTTP Basic + credentials rather than a bearer token, so the cross-cutting bearer + token concerns do not apply to it. """ - endpoint_fixture: Endpoint = request.getfixturevalue(request.param) + endpoint_fixture: ModelTargetEndpoint = request.getfixturevalue( + argname=request.param, + ) return endpoint_fixture @pytest.fixture( params=[ pytest.param( - 'abcde', - id='Length is one more than a multiple of four.', + "abcde", + id="Length is one more than a multiple of four.", ), pytest.param( # We choose XN because it is different when decoded then encoded: # - # print(base64.b64encode(base64.b64decode('XN=='))) # # prints ``XA==``. - 'XN', - id='Length is two more than a multiple of four.', + "XN", + id="Length is two more than a multiple of four.", ), pytest.param( - 'XNA', - id='Length is three more than a multiple of four.', + "XNA", + id="Length is three more than a multiple of four.", ), ], ) -def not_base64_encoded_processable(request: SubRequest) -> str: - """ - Return a string which is not decodable as base64 data, but Vuforia will +def not_base64_encoded_processable(*, request: pytest.FixtureRequest) -> str: + """Return a string which is not decodable as base64 data, but Vuforia + will respond as if this is valid base64 data. + ``UNPROCESSABLE_ENTITY`` when this is given. """ not_base64_encoded_string: str = request.param - with pytest.raises(binascii.Error): - base64.b64decode(not_base64_encoded_string, validate=True) + with pytest.raises(expected_exception=binascii.Error): + base64.b64decode(s=not_base64_encoded_string, validate=True) return not_base64_encoded_string @@ -133,19 +252,23 @@ def not_base64_encoded_processable(request: SubRequest) -> str: params=[ pytest.param( 'aaa"', - id='Includes a character which is not a base64 digit.', + id="Includes a character which is not a base64 digit.", ), - pytest.param('"', id='Not a base64 character.'), + pytest.param('"', id="Not a base64 character."), ], ) -def not_base64_encoded_not_processable(request: SubRequest) -> str: +def not_base64_encoded_not_processable( + *, + request: pytest.FixtureRequest, +) -> str: """ - Return a string which is not decodable as base64 data, and Vuforia will + Return a string which is not decodable as base64 data, and Vuforia + will return an ``UNPROCESSABLE_ENTITY`` response when this is given. """ not_base64_encoded_string: str = request.param - with pytest.raises(binascii.Error): - base64.b64decode(not_base64_encoded_string, validate=True) + with pytest.raises(expected_exception=binascii.Error): + base64.b64decode(s=not_base64_encoded_string, validate=True) return not_base64_encoded_string diff --git a/tests/mock_vws/__init__.py b/tests/mock_vws/__init__.py index 7af10c085..55becaf36 100644 --- a/tests/mock_vws/__init__.py +++ b/tests/mock_vws/__init__.py @@ -1,7 +1,5 @@ -""" -A mock implementation of Vuforia Web Services. -""" +"""A mock implementation of Vuforia Web Services.""" import pytest -pytest.register_assert_rewrite('tests.mock_vws.utils') +pytest.register_assert_rewrite("tests.mock_vws.utils") diff --git a/tests/mock_vws/fixtures/__init__.py b/tests/mock_vws/fixtures/__init__.py index fd67d619b..3ab52191a 100644 --- a/tests/mock_vws/fixtures/__init__.py +++ b/tests/mock_vws/fixtures/__init__.py @@ -1,3 +1 @@ -""" -Common fixtures. -""" +"""Common fixtures.""" diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index 27ad5c94d..dfedf8a2a 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -1,44 +1,190 @@ -""" -Fixtures for credentials for Vuforia databases. -""" +"""Fixtures for credentials for Vuforia databases.""" -import os +from dataclasses import dataclass, field +from pathlib import Path +from uuid import uuid4 import pytest +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from mock_vws.states import States -@pytest.fixture() -def vuforia_database() -> VuforiaDatabase: +class _CloudDatabaseSettings(BaseSettings): + """Settings for a Vuforia database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + client_access_key: str + client_secret_key: str + + model_config = SettingsConfigDict( + env_prefix="VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _WorkingCloudDatabaseSettings(_CloudDatabaseSettings): + """Settings for the working Vuforia database. + + Only the working database has an ID, because only endpoints which name + a database in their path need one. """ - Return VWS credentials from environment variables. + + database_id: str + + +class _InactiveCloudDatabaseSettings(_CloudDatabaseSettings): + """Settings for an inactive Vuforia database.""" + + model_config = SettingsConfigDict( + env_prefix="INACTIVE_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _InactiveVuMarkDatabaseSettings(BaseSettings): + """Settings for an inactive VuMark database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + + model_config = SettingsConfigDict( + env_prefix="INACTIVE_VUMARK_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _VuMarkCloudDatabaseSettings(BaseSettings): + """Settings for a VuMark Vuforia database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + target_id: str + processing_target_id: str = Field(default_factory=lambda: uuid4().hex) + + model_config = SettingsConfigDict( + env_prefix="VUMARK_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _ModelTargetSettings(BaseSettings): + """Settings for the Model Target Web API.""" + + client_id: str + client_secret: str + cad_data_url: str + + model_config = SettingsConfigDict( + env_prefix="MODEL_TARGET_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +@dataclass(frozen=True, kw_only=True) +class InactiveVuMarkCloudDatabase: + """Credentials for an inactive VuMark database.""" + + target_manager_database_name: str = field(repr=False) + server_access_key: str = field(repr=False) + server_secret_key: str = field(repr=False) + + +@dataclass(frozen=True, kw_only=True) +class VuMarkCloudDatabase: + """Credentials for the VuMark generation API.""" + + target_manager_database_name: str = field(repr=False) + server_access_key: str = field(repr=False) + server_secret_key: str = field(repr=False) + target_id: str = field(repr=False) + processing_target_id: str = field(repr=False) + + +@dataclass(frozen=True, kw_only=True) +class ModelTargetCredentials: + """Credentials and input data for the Model Target Web API.""" + + client_id: str = field(repr=False) + client_secret: str = field(repr=False) + cad_data_url: str = field(repr=False) + + +def get_model_target_credentials() -> ModelTargetCredentials: + """Return Model Target Web API credentials from environment + variables. """ - credentials: VuforiaDatabase = VuforiaDatabase( - database_name=os.environ['VUFORIA_TARGET_MANAGER_DATABASE_NAME'], - server_access_key=os.environ['VUFORIA_SERVER_ACCESS_KEY'], - server_secret_key=os.environ['VUFORIA_SERVER_SECRET_KEY'], - client_access_key=os.environ['VUFORIA_CLIENT_ACCESS_KEY'], - client_secret_key=os.environ['VUFORIA_CLIENT_SECRET_KEY'], + settings = _ModelTargetSettings.model_validate(obj={}) + return ModelTargetCredentials( + client_id=settings.client_id, + client_secret=settings.client_secret, + cad_data_url=settings.cad_data_url, + ) + + +@pytest.fixture +def vuforia_database() -> CloudDatabase: + """Return VWS credentials from environment variables.""" + settings = _WorkingCloudDatabaseSettings.model_validate(obj={}) + return CloudDatabase( + database_id=settings.database_id, + database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + client_access_key=settings.client_access_key, + client_secret_key=settings.client_secret_key, state=States.WORKING, ) - return credentials -@pytest.fixture() -def inactive_database() -> VuforiaDatabase: +@pytest.fixture +def inactive_cloud_database() -> CloudDatabase: """ - Return VWS credentials for an inactive project from environment variables. + Return VWS credentials for an inactive project from environment + variables. """ - credentials: VuforiaDatabase = VuforiaDatabase( - database_name=os.environ[ - 'INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME' - ], - server_access_key=os.environ['INACTIVE_VUFORIA_SERVER_ACCESS_KEY'], - server_secret_key=os.environ['INACTIVE_VUFORIA_SERVER_SECRET_KEY'], - client_access_key=os.environ['INACTIVE_VUFORIA_CLIENT_ACCESS_KEY'], - client_secret_key=os.environ['INACTIVE_VUFORIA_CLIENT_SECRET_KEY'], + settings = _InactiveCloudDatabaseSettings.model_validate(obj={}) + return CloudDatabase( + database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + client_access_key=settings.client_access_key, + client_secret_key=settings.client_secret_key, state=States.PROJECT_INACTIVE, ) - return credentials + + +@pytest.fixture +def inactive_vumark_database() -> InactiveVuMarkCloudDatabase: + """Return inactive VuMark credentials from environment variables.""" + settings = _InactiveVuMarkDatabaseSettings.model_validate(obj={}) + return InactiveVuMarkCloudDatabase( + target_manager_database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + ) + + +@pytest.fixture +def vumark_vuforia_database() -> VuMarkCloudDatabase: + """Return VuMark VWS credentials from environment variables.""" + settings = _VuMarkCloudDatabaseSettings.model_validate(obj={}) + + return VuMarkCloudDatabase( + target_manager_database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + target_id=settings.target_id, + processing_target_id=settings.processing_target_id, + ) diff --git a/tests/mock_vws/fixtures/model_target_prepared_requests.py b/tests/mock_vws/fixtures/model_target_prepared_requests.py new file mode 100644 index 000000000..11741d835 --- /dev/null +++ b/tests/mock_vws/fixtures/model_target_prepared_requests.py @@ -0,0 +1,208 @@ +"""Fixtures which prepare Model Target Web API requests.""" + +import json +from http import HTTPMethod, HTTPStatus +from typing import Any + +import pytest +import requests +from beartype import beartype + +from tests.mock_vws.fixtures.credentials import ( + ModelTargetCredentials, + get_model_target_credentials, +) +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend +from tests.mock_vws.utils import ModelTargetEndpoint + +MODEL_TARGET_VWS_HOST = "https://vws.vuforia.com" +MODEL_TARGET_DATASET_UUID = "0b12466eee5d49409a440927006ff5d8" + +_DATASET_REQUEST: dict[str, Any] = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + + +@beartype +def credentials_for_backend( + *, + backend: VuforiaBackend, +) -> ModelTargetCredentials: + """Return Model Target credentials for the chosen backend.""" + if backend == VuforiaBackend.REAL: + return get_model_target_credentials() + + return ModelTargetCredentials( + client_id="client-id", + client_secret="client-secret", + cad_data_url="https://example.com/model.glb", + ) + + +@beartype +def get_access_token( + *, + credentials: ModelTargetCredentials, + backend: VuforiaBackend, +) -> str: + """Return an OAuth2 access token.""" + response = requests.post( + url=f"{MODEL_TARGET_VWS_HOST}/oauth2/token", + auth=(credentials.client_id, credentials.client_secret), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + + if ( + backend == VuforiaBackend.REAL + and response.status_code == HTTPStatus.UNAUTHORIZED + and response.json() == {"error": "invalid_client"} + ): + pytest.xfail( + reason=( + "Real Model Target Web API credentials are not accepted; " + "authenticated behavior is verified against the mock " + "backends only until the credentials are rotated." + ), + ) + + assert response.status_code == HTTPStatus.OK + response_json: dict[str, Any] = json.loads(s=response.text) + access_token = response_json["access_token"] + assert isinstance(access_token, str) + assert response_json["token_type"] == "bearer" + return access_token + + +@beartype +def _create_dataset_endpoint(*, request_path: str) -> ModelTargetEndpoint: + """Return details of a dataset creation endpoint.""" + content = json.dumps(obj=_DATASET_REQUEST).encode(encoding="utf-8") + headers = { + "Content-Length": str(object=len(content)), + "Content-Type": "application/json", + } + return ModelTargetEndpoint( + base_url=MODEL_TARGET_VWS_HOST, + path_url=request_path, + method=HTTPMethod.POST, + headers=headers, + data=content, + takes_json_body=True, + ) + + +@beartype +def _get_endpoint(*, request_path: str) -> ModelTargetEndpoint: + """Return details of a body-less ``GET`` endpoint.""" + return ModelTargetEndpoint( + base_url=MODEL_TARGET_VWS_HOST, + path_url=request_path, + method=HTTPMethod.GET, + headers={}, + data=b"", + takes_json_body=False, + ) + + +@beartype +def _delete_endpoint(*, request_path: str) -> ModelTargetEndpoint: + """Return details of a body-less ``DELETE`` endpoint.""" + return ModelTargetEndpoint( + base_url=MODEL_TARGET_VWS_HOST, + path_url=request_path, + method=HTTPMethod.DELETE, + headers={"Content-Length": "0"}, + data=b"", + takes_json_body=False, + ) + + +@pytest.fixture +def create_standard_dataset() -> ModelTargetEndpoint: + """Return details of the endpoint for creating a standard dataset.""" + return _create_dataset_endpoint(request_path="/modeltargets/datasets") + + +@pytest.fixture +def create_advanced_dataset() -> ModelTargetEndpoint: + """Return details of the endpoint for creating an advanced dataset.""" + return _create_dataset_endpoint( + request_path="/modeltargets/advancedDatasets", + ) + + +@pytest.fixture +def standard_dataset_status() -> ModelTargetEndpoint: + """Return details of the standard dataset status endpoint.""" + return _get_endpoint( + request_path=( + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/status" + ), + ) + + +@pytest.fixture +def advanced_dataset_status() -> ModelTargetEndpoint: + """Return details of the advanced dataset status endpoint.""" + return _get_endpoint( + request_path=( + f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" + "/status" + ), + ) + + +@pytest.fixture +def download_standard_dataset() -> ModelTargetEndpoint: + """Return details of the standard dataset download endpoint.""" + return _get_endpoint( + request_path=( + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/dataset" + ), + ) + + +@pytest.fixture +def download_advanced_dataset() -> ModelTargetEndpoint: + """Return details of the advanced dataset download endpoint.""" + return _get_endpoint( + request_path=( + f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" + "/dataset" + ), + ) + + +@pytest.fixture +def delete_standard_dataset() -> ModelTargetEndpoint: + """Return details of the standard dataset deletion endpoint.""" + return _delete_endpoint( + request_path=f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}", + ) + + +@pytest.fixture +def delete_advanced_dataset() -> ModelTargetEndpoint: + """Return details of the advanced dataset deletion endpoint.""" + return _delete_endpoint( + request_path=( + f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" + ), + ) diff --git a/tests/mock_vws/fixtures/prepared_requests.py b/tests/mock_vws/fixtures/prepared_requests.py index 74d1939f6..393d70800 100644 --- a/tests/mock_vws/fixtures/prepared_requests.py +++ b/tests/mock_vws/fixtures/prepared_requests.py @@ -1,50 +1,47 @@ -""" -Fixtures which prepare requests. -""" +"""Fixtures which prepare requests.""" import base64 import io import json -from http import HTTPStatus -from typing import Any, Dict -from urllib.parse import urljoin +from http import HTTPMethod, HTTPStatus +from typing import Any +from uuid import uuid4 import pytest -import requests -from requests_mock import DELETE, GET, POST, PUT from urllib3.filepost import encode_multipart_formdata -from vws import VWS from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase +from tests.mock_vws.fixtures.credentials import VuMarkCloudDatabase from tests.mock_vws.utils import Endpoint -VWS_HOST = 'https://vws.vuforia.com' -VWQ_HOST = 'https://cloudreco.vuforia.com' +VWS_HOST = "https://vws.vuforia.com" +VWQ_HOST = "https://cloudreco.vuforia.com" -@pytest.fixture() -def _add_target( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def add_target( + *, + vuforia_database: CloudDatabase, image_file_failed_state: io.BytesIO, ) -> Endpoint: - """ - Return details of the endpoint for adding a target. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """Return details of the endpoint for adding a target.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) date = rfc_1123_date() - data: Dict[str, Any] = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, + data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, } - request_path = '/targets' - content_type = 'application/json' - method = POST + request_path = "/targets" + content_type = "application/json" + method = HTTPMethod.POST - content = bytes(json.dumps(data), encoding='utf-8') + content = json.dumps(obj=data).encode(encoding="utf-8") access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -59,43 +56,36 @@ def _add_target( ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.CREATED, successful_headers_result_code=ResultCodes.TARGET_CREATED, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _delete_target( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def delete_target( + *, + vuforia_database: CloudDatabase, target_id: str, - vws_client: VWS, ) -> Endpoint: - """ - Return details of the endpoint for deleting a target. - """ - vws_client.wait_for_target_processed(target_id=target_id) + """Return details of the endpoint for deleting a target.""" date = rfc_1123_date() - request_path = f'/targets/{target_id}' - method = DELETE - content = b'' + request_path = f"/targets/{target_id}" + method = HTTPMethod.DELETE + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -104,43 +94,41 @@ def _delete_target( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), } - request = requests.Request( + return Endpoint( + base_url=VWS_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWS_HOST, url=request_path), headers=headers, data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _database_summary(vuforia_database: VuforiaDatabase) -> Endpoint: +@pytest.fixture +def database_summary(*, vuforia_database: CloudDatabase) -> Endpoint: """ - Return details of the endpoint for getting details about the database. + Return details of the endpoint for getting details about the + database. """ date = rfc_1123_date() - request_path = '/summary' - method = GET + request_path = "/summary" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -149,50 +137,45 @@ def _database_summary(vuforia_database: VuforiaDatabase) -> Endpoint: secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( + return Endpoint( + base_url=VWS_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWS_HOST, url=request_path), headers=headers, data=content, - ) - - prepared_request = request.prepare() - - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _get_duplicates( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def get_duplicates( + *, + vuforia_database: CloudDatabase, target_id: str, - vws_client: VWS, ) -> Endpoint: """ Return details of the endpoint for getting potential duplicates of a target. """ - vws_client.wait_for_target_processed(target_id=target_id) date = rfc_1123_date() - request_path = f'/duplicates/{target_id}' - method = GET + request_path = f"/duplicates/{target_id}" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -201,49 +184,42 @@ def _get_duplicates( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( + return Endpoint( + base_url=VWS_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWS_HOST, url=request_path), headers=headers, data=content, - ) - - prepared_request = request.prepare() - - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _get_target( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def get_target( + *, + vuforia_database: CloudDatabase, target_id: str, - vws_client: VWS, ) -> Endpoint: - """ - Return details of the endpoint for getting details of a target. - """ - vws_client.wait_for_target_processed(target_id=target_id) + """Return details of the endpoint for getting details of a target.""" date = rfc_1123_date() - request_path = f'/targets/{target_id}' - method = GET + request_path = f"/targets/{target_id}" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -252,44 +228,38 @@ def _get_target( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _target_list(vuforia_database: VuforiaDatabase) -> Endpoint: - """ - Return details of the endpoint for getting a list of targets. - """ +@pytest.fixture +def target_list(*, vuforia_database: CloudDatabase) -> Endpoint: + """Return details of the endpoint for getting a list of targets.""" date = rfc_1123_date() - request_path = '/targets' - method = GET + request_path = "/targets" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -298,49 +268,45 @@ def _target_list(vuforia_database: VuforiaDatabase) -> Endpoint: secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _target_summary( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def target_summary( + *, + vuforia_database: CloudDatabase, target_id: str, - vws_client: VWS, ) -> Endpoint: """ - Return details of the endpoint for getting a summary report of a target. + Return details of the endpoint for getting a summary report of a + target. """ - vws_client.wait_for_target_processed(target_id=target_id) date = rfc_1123_date() - request_path = f'/summary/{target_id}' - method = GET + request_path = f"/summary/{target_id}" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -349,51 +315,44 @@ def _target_summary( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _update_target( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def update_target( + *, + vuforia_database: CloudDatabase, target_id: str, - vws_client: VWS, ) -> Endpoint: - """ - Return details of the endpoint for updating a target. - """ - vws_client.wait_for_target_processed(target_id=target_id) - data: Dict[str, Any] = {} - request_path = f'/targets/{target_id}' - content = bytes(json.dumps(data), encoding='utf-8') - content_type = 'application/json' + """Return details of the endpoint for updating a target.""" + data: dict[str, Any] = {} + request_path = f"/targets/{target_id}" + content = json.dumps(obj=data).encode(encoding="utf-8") + content_type = "application/json" date = rfc_1123_date() - method = PUT + method = HTTPMethod.PUT access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -408,44 +367,42 @@ def _update_target( ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _query( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def query( + *, + vuforia_database: CloudDatabase, high_quality_image: io.BytesIO, ) -> Endpoint: """ - Return details of the endpoint for making an image recognition query. + Return details of the endpoint for making an image recognition + query. """ - image_content = high_quality_image.read() + image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - files = {'image': ('image.jpeg', image_content, 'image/jpeg')} - method = POST + request_path = "/v1/query" + files = {"image": ("image.jpeg", image_content, "image/jpeg")} + method = HTTPMethod.POST - content, content_type_header = encode_multipart_formdata(files) + content, content_type_header = encode_multipart_formdata(fields=files) access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -455,30 +412,73 @@ def _query( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, + "Content-Type": content_type_header, } - request = requests.Request( + return Endpoint( + successful_headers_status_code=HTTPStatus.OK, + successful_headers_result_code=ResultCodes.SUCCESS, + base_url=VWQ_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + access_key=access_key, + secret_key=secret_key, + ) + + +@pytest.fixture +def vumark_generate_instance( + *, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> Endpoint: + """Return details of the endpoint for generating a VuMark instance.""" + request_path = f"/targets/{vumark_vuforia_database.target_id}/instances" + content_type = "application/json" + method = HTTPMethod.POST + content = json.dumps(obj={"instance_id": uuid4().hex}).encode( + encoding="utf-8" + ) + date = rfc_1123_date() + + access_key = vumark_vuforia_database.server_access_key + secret_key = vumark_vuforia_database.server_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + content_type=content_type, + date=date, + request_path=request_path, ) - prepared_request = request.prepare() + headers = { + "Accept": "image/png", + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + } return Endpoint( successful_headers_status_code=HTTPStatus.OK, - successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + successful_headers_result_code=None, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index f469a505b..b82079420 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -1,28 +1,42 @@ -""" -Choose which backends to use for the tests. -""" +"""Choose which backends to use for the tests.""" +import contextlib import logging -import os +from collections.abc import Generator from enum import Enum -from typing import Generator import pytest -from _pytest.fixtures import SubRequest +import requests +import responses +from beartype import beartype +from requests_mock_flask import add_flask_app_to_mock from vws import VWS -from vws.exceptions import TargetStatusNotSuccess +from vws.exceptions.vws_exceptions import ( + TargetStatusNotSuccessError, +) from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws._flask_server.target_manager import TARGET_MANAGER_FLASK_APP +from mock_vws._flask_server.vwq import CLOUDRECO_FLASK_APP +from mock_vws._flask_server.vws import VWS_FLASK_APP +from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.states import States +from mock_vws.target import VuMarkTarget +from tests.backend_harness import add_skip_options, backend_fixture +from tests.mock_vws.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, + VuMarkCloudDatabase, +) +from tests.mock_vws.utils.retries import RETRY_ON_TRANSIENT_VWS_FAILURE -LOGGER = logging.getLogger(__name__) -LOGGER.setLevel(logging.DEBUG) +LOGGER = logging.getLogger(name=__name__) +LOGGER.setLevel(level=logging.DEBUG) -def _delete_all_targets(database_keys: VuforiaDatabase) -> None: - """ - Delete all targets. +@beartype +@RETRY_ON_TRANSIENT_VWS_FAILURE +def _delete_all_targets(*, database_keys: CloudDatabase) -> None: + """Delete all targets. Args: database_keys: The credentials to the Vuforia target database to delete @@ -36,31 +50,76 @@ def _delete_all_targets(database_keys: VuforiaDatabase) -> None: targets = vws_client.list_targets() for target in targets: - vws_client.wait_for_target_processed(target_id=target) + vws_client.wait_for_target_processed( + target_id=target, + # Setting this to 2 is an attempt to avoid 429 Too Many Requests + # errors. + seconds_between_requests=2, + ) # Even deleted targets can be matched by a query for a few seconds so # we change the target to inactive before deleting it. - try: + with contextlib.suppress(TargetStatusNotSuccessError): vws_client.update_target(target_id=target, active_flag=False) - except TargetStatusNotSuccess: - pass vws_client.wait_for_target_processed(target_id=target) vws_client.delete_target(target_id=target) +@beartype +def _vumark_database( + *, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> VuMarkDatabase: + """Return a database with a VuMark target for VuMark instance + generation. + """ + vumark_target = VuMarkTarget( + name="mock-vumark-target", + target_id=vumark_vuforia_database.target_id, + ) + processing_target = VuMarkTarget( + name="mock-processing-vumark-target", + target_id=vumark_vuforia_database.processing_target_id, + processing_time_seconds=9999, + ) + return VuMarkDatabase( + database_name=vumark_vuforia_database.target_manager_database_name, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + vumark_targets={vumark_target, processing_target}, + ) + + +@beartype def _enable_use_real_vuforia( - working_database: VuforiaDatabase, - inactive_database: VuforiaDatabase, -) -> Generator: - assert inactive_database + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the real Vuforia.""" + assert monkeypatch + assert inactive_cloud_database + assert vumark_vuforia_database + assert inactive_vumark_database _delete_all_targets(database_keys=working_database) yield +@beartype def _enable_use_mock_vuforia( - working_database: VuforiaDatabase, - inactive_database: VuforiaDatabase, -) -> Generator: - working_database = VuforiaDatabase( + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the in-memory mock Vuforia.""" + assert monkeypatch + working_database = CloudDatabase( + database_id=working_database.database_id, database_name=working_database.database_name, server_access_key=working_database.server_access_key, server_secret_key=working_database.server_secret_key, @@ -68,56 +127,335 @@ def _enable_use_mock_vuforia( client_secret_key=working_database.client_secret_key, ) - inactive_database = VuforiaDatabase( + inactive_cloud_database = CloudDatabase( + state=States.PROJECT_INACTIVE, + database_name=inactive_cloud_database.database_name, + server_access_key=inactive_cloud_database.server_access_key, + server_secret_key=inactive_cloud_database.server_secret_key, + client_access_key=inactive_cloud_database.client_access_key, + client_secret_key=inactive_cloud_database.client_secret_key, + ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) + inactive_vumark_db = VuMarkDatabase( state=States.PROJECT_INACTIVE, - database_name=inactive_database.database_name, - server_access_key=inactive_database.server_access_key, - server_secret_key=inactive_database.server_secret_key, - client_access_key=inactive_database.client_access_key, - client_secret_key=inactive_database.client_secret_key, + database_name=inactive_vumark_database.target_manager_database_name, + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=working_database) + mock.add_cloud_database(cloud_database=inactive_cloud_database) + mock.add_vumark_database(vumark_database=vumark_database) + mock.add_vumark_database(vumark_database=inactive_vumark_db) + yield + + +@beartype +def _enable_use_docker_in_memory( + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against mock Vuforia created to be run in a container.""" + # We set ``wsgi.input_terminated`` to ``True`` so that when going through + # ``requests`` in our tests, the Flask applications + # have the given ``Content-Length`` headers and the given data in + # ``request.headers`` and ``request.data``. + # + # We do not set these in the Flask application itself. + # This is because when running the Flask application, if this is set, + # reading ``request.data`` hangs. + # + # Therefore, when running the real Flask application, the behavior is not + # the same as the real Vuforia. + # This is documented as a difference in the documentation for this package. + VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + CLOUDRECO_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + + target_manager_base_url = "http://example.com" + monkeypatch.setenv( + name="TARGET_MANAGER_BASE_URL", + value=target_manager_base_url, + ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) + inactive_vumark_db = VuMarkDatabase( + state=States.PROJECT_INACTIVE, + database_name=inactive_vumark_database.target_manager_database_name, + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + ) + + with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: + add_flask_app_to_mock( + mock_obj=mock, + flask_app=VWS_FLASK_APP, + base_url="https://vws.vuforia.com", + ) + + add_flask_app_to_mock( + mock_obj=mock, + flask_app=CLOUDRECO_FLASK_APP, + base_url="https://cloudreco.vuforia.com", + ) + + add_flask_app_to_mock( + mock_obj=mock, + flask_app=TARGET_MANAGER_FLASK_APP, + base_url=target_manager_base_url, + ) + + cloud_databases_url = target_manager_base_url + "/cloud_databases" + vumark_databases_url = target_manager_base_url + "/vumark_databases" + + for database in requests.get( + url=cloud_databases_url, timeout=30 + ).json(): + requests.delete( + url=cloud_databases_url + "/" + database["database_name"], + timeout=30, + ) + for database in requests.get( + url=vumark_databases_url, timeout=30 + ).json(): + requests.delete( + url=vumark_databases_url + "/" + database["database_name"], + timeout=30, + ) + + requests.post( + url=cloud_databases_url, + json=working_database.to_dict(), + timeout=30, + ) + requests.post( + url=cloud_databases_url, + json=inactive_cloud_database.to_dict(), + timeout=30, + ) + requests.post( + url=vumark_databases_url, + json=vumark_database.to_dict(), + timeout=30, + ) + requests.post( + url=vumark_databases_url, + json=inactive_vumark_db.to_dict(), + timeout=30, + ) + for vumark_target in vumark_database.vumark_targets: + requests.post( + url=( + f"{vumark_databases_url}" + f"/{vumark_database.database_name}/vumark_targets" + ), + json=vumark_target.to_dict(), + timeout=30, + ) + + yield + + +@beartype +def _enable_use_real_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the real Model Target Web API.""" + assert monkeypatch + yield + + +@beartype +def _enable_use_mock_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the in-memory mock Model Target Web API.""" + assert monkeypatch + with MockVWS(): + yield + + +@beartype +def _enable_use_docker_in_memory_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the Flask-backed mock Model Target Web API.""" + assert monkeypatch + VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + target_manager_base_url = "http://example.com" + monkeypatch.setenv( + name="TARGET_MANAGER_BASE_URL", + value=target_manager_base_url, ) - with MockVWS(processing_time_seconds=0.2) as mock: - mock.add_database(database=working_database) - mock.add_database(database=inactive_database) + with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: + add_flask_app_to_mock( + mock_obj=mock, + flask_app=VWS_FLASK_APP, + base_url="https://vws.vuforia.com", + ) + + # The VWS app stores Model Target datasets in the target manager + # service, just as it does cloud databases. + add_flask_app_to_mock( + mock_obj=mock, + flask_app=TARGET_MANAGER_FLASK_APP, + base_url=target_manager_base_url, + ) + yield class VuforiaBackend(Enum): + """Backends for tests.""" + + REAL = "Real Vuforia" + MOCK = "In Memory Mock Vuforia" + DOCKER_IN_MEMORY = "In Memory version of Docker application" + + +_ALL_BACKENDS = list(VuforiaBackend) +# The real Vuforia cannot be set up for tests which need to control the +# state of the service. +_MOCK_BACKENDS = [ + backend for backend in _ALL_BACKENDS if backend != VuforiaBackend.REAL +] + +# These deliberately have no type annotation, so that the keyword +# arguments of the setup functions are still checked where they are +# bound. +_SETUP_FUNCTIONS = { + VuforiaBackend.REAL: _enable_use_real_vuforia, + VuforiaBackend.MOCK: _enable_use_mock_vuforia, + VuforiaBackend.DOCKER_IN_MEMORY: _enable_use_docker_in_memory, +} + +_MODEL_TARGET_SETUP_FUNCTIONS = { + VuforiaBackend.REAL: _enable_use_real_model_target_vuforia, + VuforiaBackend.MOCK: _enable_use_mock_model_target_vuforia, + VuforiaBackend.DOCKER_IN_MEMORY: ( + _enable_use_docker_in_memory_model_target_vuforia + ), +} + + +@beartype +def pytest_addoption(parser: pytest.Parser) -> None: """ - Backends for tests. + Add options to the pytest command line for skipping tests with + particular + backends. """ + add_skip_options(parser=parser, backends=_ALL_BACKENDS) - REAL = 'Real Vuforia' - MOCK = 'In Memory Mock Vuforia' + parser.addoption( + "--skip-docker_build_tests", + action="store_true", + default=False, + help="Skip tests for building Docker images", + ) -@pytest.fixture( - params=list(VuforiaBackend), - ids=[backend.value for backend in list(VuforiaBackend)], -) -def verify_mock_vuforia( - request: SubRequest, - vuforia_database: VuforiaDatabase, - inactive_database: VuforiaDatabase, -) -> Generator: - """ - Test functions which use this fixture are run twice. Once with the real - Vuforia, and once with the mock. +@beartype +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + """Skip Docker tests if requested.""" + skip_docker_build_tests_option = "--skip-docker_build_tests" + skip_docker_build_tests_marker = pytest.mark.skip( + reason=( + "Skipping docker build tests because " + f"{skip_docker_build_tests_option} was set" + ), + ) + if config.getoption(name=skip_docker_build_tests_option): + for item in items: + if "requires_docker_build" in item.keywords: + item.add_marker(marker=skip_docker_build_tests_marker) + + +@beartype +def _setup_backend( + *, + backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set a backend up with the databases which the tests use. - This is useful for verifying the mock. + Yields: + ``None``, once the backend is set up. """ - backend = request.param - should_skip = bool(os.getenv(f'SKIP_{backend.name}') == '1') - if should_skip: # pragma: no cover - pytest.skip() + yield from _SETUP_FUNCTIONS[backend]( + working_database=request.getfixturevalue(argname="vuforia_database"), + inactive_cloud_database=request.getfixturevalue( + argname="inactive_cloud_database", + ), + vumark_vuforia_database=request.getfixturevalue( + argname="vumark_vuforia_database", + ), + inactive_vumark_database=request.getfixturevalue( + argname="inactive_vumark_database", + ), + monkeypatch=request.getfixturevalue(argname="monkeypatch"), + ) + - enable_function = { - VuforiaBackend.REAL: _enable_use_real_vuforia, - VuforiaBackend.MOCK: _enable_use_mock_vuforia, - }[backend] +@beartype +def _setup_model_target_backend( + *, + backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set a backend up for the Model Target Web API tests. - yield from enable_function( - working_database=vuforia_database, - inactive_database=inactive_database, + Yields: + ``None``, once the backend is set up. + """ + yield from _MODEL_TARGET_SETUP_FUNCTIONS[backend]( + monkeypatch=request.getfixturevalue(argname="monkeypatch"), ) + + +# Tests which use this are run against the real Vuforia and against each +# mock. This is useful for verifying the mocks. +fixture_verify_mock_vuforia = backend_fixture( + name="verify_mock_vuforia", + backends=_ALL_BACKENDS, + setup_for=_setup_backend, +) + +# Model Target Web API contract tests, run against the real Vuforia and +# against each mock. +fixture_verify_model_target_mock_vuforia = backend_fixture( + name="verify_model_target_mock_vuforia", + backends=_ALL_BACKENDS, + setup_for=_setup_model_target_backend, +) + +# Model Target Web API tests which need scopes that the real test account does +# not have, run against each mock only. +fixture_model_target_mock_only_vuforia = backend_fixture( + name="model_target_mock_only_vuforia", + backends=_MOCK_BACKENDS, + setup_for=_setup_model_target_backend, +) + +# Tests which use this are run against each mock, and not against the +# real Vuforia. This is useful for testing the mock using fixtures which +# connect to Vuforia. +fixture_mock_only_vuforia = backend_fixture( + name="mock_only_vuforia", + backends=_MOCK_BACKENDS, + setup_for=_setup_backend, +) diff --git a/tests/mock_vws/test_add_target.py b/tests/mock_vws/test_add_target.py index f4682e252..02b848654 100644 --- a/tests/mock_vws/test_add_target.py +++ b/tests/mock_vws/test_add_target.py @@ -1,104 +1,72 @@ -""" -Tests for the mock of the add target endpoint. -""" +"""Tests for the mock of the add target endpoint.""" import base64 import io import json -from http import HTTPStatus +from http import HTTPMethod, HTTPStatus from string import hexdigits -from typing import Any, Dict, Union -from urllib.parse import urljoin +from typing import Any, Final import pytest -import requests -from requests import Response -from requests_mock import POST +from beartype import beartype from vws import VWS -from vws_auth_tools import authorization_header, rfc_1123_date +from vws.exceptions.custom_exceptions import ( + ServerError, +) +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + BadImageError, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectInactiveError, + TargetNameExistError, +) +from vws.response import Response from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase -from tests.mock_vws.utils import make_image_file +from tests.mock_vws.utils import ( + make_decompression_bomb_image_file, + make_image_file, + make_single_color_image_file, +) from tests.mock_vws.utils.assertions import ( - assert_valid_date_header, assert_vws_failure, assert_vws_response, ) +_MAX_METADATA_BYTES: Final[int] = 1024 * 1024 - 1 -def add_target_to_vws( - vuforia_database: VuforiaDatabase, - data: Dict[str, Any], - content_type: str = 'application/json', + +@beartype +def _add_target_to_vws( + *, + vws_client: VWS, + data: dict[str, Any], + content_type: str, ) -> Response: - """ - Return a response from a request to the endpoint to add a target. + """Return a response from a request to the endpoint to add a target. Args: - vuforia_database: The credentials to use to connect to Vuforia. + vws_client: The client to use to connect to Vuforia. data: The data to send, in JSON format, to the endpoint. content_type: The `Content-Type` header to use. Returns: The response returned by the API. """ - date = rfc_1123_date() - request_path = '/targets' - - content = bytes(json.dumps(data), encoding='utf-8') - - authorization_string = authorization_header( - access_key=vuforia_database.server_access_key, - secret_key=vuforia_database.server_secret_key, - method=POST, - content=content, - content_type=content_type, - date=date, - request_path=request_path, - ) - - headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, - } - - response = requests.request( - method=POST, - url=urljoin(base='https://vws.vuforia.com/', url=request_path), - headers=headers, + content = json.dumps(obj=data).encode(encoding="utf-8") + return vws_client.make_request( + method=HTTPMethod.POST, data=content, + request_path="/targets", + expected_result_code=ResultCodes.TARGET_CREATED.value, + content_type=content_type, ) - return response - - -def _assert_oops_response(response: Response) -> None: - """ - Assert that the response is in the format of Vuforia's "Oops, an error - occurred" HTML response. - - Raises: - AssertionError: The given response is not in the expected format. - """ - assert_valid_date_header(response=response) - assert 'Oops, an error occurred' in response.text - assert 'This exception has been logged with id' in response.text - - expected_headers = { - 'Content-Type': 'text/html; charset=UTF-8', - 'Date': response.headers['Date'], - 'Server': 'nginx', - 'Content-Length': '1172', - 'Connection': 'keep-alive', - } - assert dict(response.headers) == expected_headers - def assert_success(response: Response) -> None: - """ - Assert that the given response is a success response for adding a + """Assert that the given response is a success response for adding a target. Raises: @@ -110,246 +78,239 @@ def assert_success(response: Response) -> None: status_code=HTTPStatus.CREATED, result_code=ResultCodes.TARGET_CREATED, ) - expected_keys = {'result_code', 'transaction_id', 'target_id'} - assert response.json().keys() == expected_keys - target_id = response.json()['target_id'] - assert len(target_id) == 32 + expected_keys = {"result_code", "transaction_id", "target_id"} + response_json = json.loads(s=response.text) + target_id = response_json["target_id"] + expected_target_id_length = 32 + assert len(target_id) == expected_target_id_length assert all(char in hexdigits for char in target_id) + assert isinstance(response_json, dict) + assert response_json.keys() == expected_keys -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestContentTypes: - """ - Tests for the `Content-Type` header. - """ + """Tests for the `Content-Type` header.""" + @staticmethod @pytest.mark.parametrize( - 'content_type', - [ + argnames="content_type", + argvalues=[ # This is the documented required content type: - 'application/json', + "application/json", # Other content types also work. - 'other/content_type', + "other/content_type", ], ids=[ - 'Documented Content-Type', - 'Undocumented Content-Type', + "Documented Content-Type", + "Undocumented Content-Type", ], ) def test_content_types( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, content_type: str, ) -> None: - """ - Any non-empty ``Content-Type`` header is allowed. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """Any non-empty ``Content-Type`` header is allowed.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, + "name": "example", + "width": 1, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, + response = _add_target_to_vws( + vws_client=vws_client, data=data, content_type=content_type, ) assert_success(response=response) + @staticmethod def test_empty_content_type( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - An ``UNAUTHORIZED`` response is given if an empty ``Content-Type`` + An ``UNAUTHORIZED`` response is given if an empty ``Content- + Type`` header is given. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, + "name": "example", + "width": 1, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='', - ) + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNAUTHORIZED, result_code=ResultCodes.AUTHENTICATION_FAILURE, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMissingData: - """ - Tests for giving incomplete data. - """ + """Tests for giving incomplete data.""" - @pytest.mark.parametrize('data_to_remove', ['name', 'width', 'image']) + @staticmethod + @pytest.mark.parametrize( + argnames="data_to_remove", + argvalues=["name", "width", "image"], + ) def test_missing_data( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, data_to_remove: str, ) -> None: - """ - `name`, `width` and `image` are all required. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """`name`, `width` and `image` are all required.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, + "name": "example_name", + "width": 1, + "image": image_data_encoded, } data.pop(data_to_remove) - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestWidth: - """ - Tests for the target width field. - """ + """Tests for the target width field.""" + @staticmethod @pytest.mark.parametrize( - 'width', - [-1, '10', None, 0], - ids=['Negative', 'Wrong Type', 'None', 'Zero'], + argnames="width", + argvalues=[-1, "10", None, 0], + ids=["Negative", "Wrong Type", "None", "Zero"], ) def test_width_invalid( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, - width: Any, + width: int | str | None, ) -> None: - """ - The width must be a number greater than zero. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """The width must be a number greater than zero.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example_name', - 'width': width, - 'image': image_data_encoded, + "name": "example_name", + "width": width, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_width_valid( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - Positive numbers are valid widths. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example', - 'width': 0.01, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='application/json', + """Positive numbers are valid widths.""" + vws_client.add_target( + name="example", + width=0.01, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetName: - """ - Tests for the target name field. - """ + """Tests for the target name field.""" _MAX_CHAR_VALUE = 65535 _MAX_NAME_LENGTH = 64 + @staticmethod @pytest.mark.parametrize( - 'name', - [ - 'á', + argnames="name", + argvalues=[ + "á", # We test just below the max character value. # This is because targets with the max character value in their # names get stuck in the processing stage. chr(_MAX_CHAR_VALUE - 2), - 'a' * _MAX_NAME_LENGTH, + "a" * _MAX_NAME_LENGTH, ], - ids=['Short name', 'Max char value', 'Long name'], + ids=["Short name", "Max char value", "Long name"], ) def test_name_valid( - self, + *, name: str, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: - """ - Names between 1 and 64 characters in length are valid. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': name, - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='application/json', + """Names between 1 and 64 characters in length are valid.""" + vws_client.add_target( + name=name, + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - + @staticmethod @pytest.mark.parametrize( - 'name,status_code', - [ + argnames=("name", "status_code"), + argvalues=[ (1, HTTPStatus.BAD_REQUEST), - ('', HTTPStatus.BAD_REQUEST), - ('a' * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST), + ("", HTTPStatus.BAD_REQUEST), + ("a" * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST), (None, HTTPStatus.BAD_REQUEST), (chr(_MAX_CHAR_VALUE + 1), HTTPStatus.INTERNAL_SERVER_ERROR), ( @@ -358,228 +319,279 @@ def test_name_valid( ), ], ids=[ - 'Wrong Type', - 'Empty', - 'Too Long', - 'None', - 'Bad char', - 'Bad char too long', + "Wrong Type", + "Empty", + "Too Long", + "None", + "Bad char", + "Bad char too long", ], ) def test_name_invalid( - self, - name: str, + *, + name: str | int | None, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, status_code: int, + vws_client: VWS, ) -> None: """ - A target's name must be a string of length 0 < N < 65, with characters + A target's name must be a string of length 0 < N < 65, with + characters in a particular range. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) data = { - 'name': name, - 'width': 1, - 'image': image_data_encoded, + "name": name, + "width": 1, + "image": image_data_encoded, + "application_metadata": None, + "active_flag": True, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) - - assert response.status_code == status_code + exc: pytest.ExceptionInfo[FailError | ServerError] if status_code == HTTPStatus.INTERNAL_SERVER_ERROR: - _assert_oops_response(response=response) - return + with pytest.raises(expected_exception=ServerError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + else: + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=status_code, result_code=ResultCodes.FAIL, ) + @staticmethod def test_existing_target_name( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: - """ - Only one target can have a given name. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + """Only one target can have a given name.""" + vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=TargetNameExistError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.TARGET_NAME_EXIST, ) + @staticmethod def test_deleted_existing_target_name( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - A target can be added with the name of a deleted target. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + """A target can be added with the name of a deleted target.""" + target_id = vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - target_id = response.json()['target_id'] - vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestImage: - """ - Tests for the image parameter. + """Tests for the image parameter. - The specification for images is documented in "Supported Images" on - https://library.vuforia.com/articles/Training/Image-Target-Guide + The specification for images is documented at + https://library.vuforia.com/features/images/image-targets.html. """ + @staticmethod def test_image_valid( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_files_failed_state: io.BytesIO, ) -> None: """ - JPEG and PNG files in the RGB and greyscale color spaces are allowed. + JPEG and PNG files in the RGB and greyscale color spaces are + allowed. """ - image_file = image_files_failed_state - image_data = image_file.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='application/json', + vws_client.add_target( + name="example_name", + width=1, + image=image_files_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - + @staticmethod def test_bad_image_format_or_color_space( - self, + *, bad_image_file: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: """ - An `UNPROCESSABLE_ENTITY` response is returned if an image which is not + An `UNPROCESSABLE_ENTITY` response is returned if an image which + is not a JPEG or PNG file is given, or if the given image is not in the greyscale or RGB color space. """ - image_data = bad_image_file.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=bad_image_file, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod def test_corrupted( - self, - vuforia_database: VuforiaDatabase, + *, corrupted_image_file: io.BytesIO, + vws_client: VWS, ) -> None: + """An error is returned when the given image is corrupted.""" + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=corrupted_image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.BAD_IMAGE, + ) + + @staticmethod + def test_decompression_bomb(vws_client: VWS) -> None: """ - No error is returned when the given image is corrupted. + An ``ImageTooLargeError`` result is returned when the given + image has a small file size but a huge number of pixels. """ - image_data = corrupted_image_file.getvalue() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + max_bytes = 2.3 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=image_file, + application_metadata=None, + active_flag=True, + ) - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.IMAGE_TOO_LARGE, + ) - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + @staticmethod + def test_image_pixel_count_too_large(vws_client: VWS) -> None: + """ + An ``ImageTooLargeError`` result is returned if the image has + more than 37748736 pixels, whatever its file size. + + This limit is not documented. + """ + max_allowed_pixels = 37_748_736 + width = height = 6144 + assert width * height == max_allowed_pixels + + image_not_too_many_pixels = make_single_color_image_file( + width=width, + height=height, ) - assert_success(response=response) + vws_client.add_target( + name="example_name", + width=1, + image=image_not_too_many_pixels, + application_metadata=None, + active_flag=True, + ) - def test_image_file_size_too_large( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + image_too_many_pixels = make_single_color_image_file( + width=width + 1, + height=height, + ) + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name_2", + width=1, + image=image_too_many_pixels, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.IMAGE_TOO_LARGE, + ) + + @staticmethod + def test_image_file_size_too_large(vws_client: VWS) -> None: """ - An ``ImageTooLarge`` result is returned if the image file size is above - a certain threshold. + An ``ImageTooLargeError`` result is returned if the image file + size is + above a certain threshold. """ max_bytes = 2.3 * 1024 * 1024 width = height = 886 png_not_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) - image_data = png_not_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_not_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -589,30 +601,24 @@ def test_image_file_size_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + vws_client.add_target( + name="example_name", + width=1, + image=png_not_too_large, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - width = width + 1 height = height + 1 png_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) - image_data = png_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -622,512 +628,507 @@ def test_image_file_size_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - data = { - 'name': 'example_name_2', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name_2", + width=1, + image=png_too_large, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.IMAGE_TOO_LARGE, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, not_base64_encoded_processable: str, ) -> None: - """ - Some strings which are not valid base64 encoded strings are allowed as - an image without getting a "Fail" response. - This is because Vuforia treats them as valid base64, but then not a - valid image. + """Some strings which are not valid base64 encoded strings are + allowed + as an image without getting a "Fail" response. + + This is because Vuforia treats them as valid base64, but then + not a valid image. """ data = { - 'name': 'example_name', - 'width': 1, - 'image': not_base64_encoded_processable, + "name": "example_name", + "width": 1, + "image": not_base64_encoded_processable, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, not_base64_encoded_not_processable: str, ) -> None: """ Some strings which are not valid base64 encoded strings are not - processable by Vuforia, and then when given as an image Vuforia returns + processable by Vuforia, and then when given as an image Vuforia + returns a "Fail" response. """ data = { - 'name': 'example_name', - 'width': 1, - 'image': not_base64_encoded_not_processable, + "name": "example_name", + "width": 1, + "image": not_base64_encoded_not_processable, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) - def test_not_image( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_not_image(vws_client: VWS) -> None: """ - If the given image is not an image file then a `BadImage` result is - returned. + If the given image is not an image file then a `BadImageError` + result + is returned. """ - not_image_data = b'not_image_data' - image_data_encoded = base64.b64encode(not_image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=io.BytesIO(initial_bytes=b"not_image_data"), + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) - @pytest.mark.parametrize('invalid_type_image', [1, None]) + @staticmethod + @pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], + ) def test_invalid_type( - self, - invalid_type_image: Any, - vuforia_database: VuforiaDatabase, + *, + invalid_type_image: int | None, + vws_client: VWS, ) -> None: - """ - If the given image is not a string, a `Fail` result is returned. - """ + """If the given image is not a string, a `Fail` result is returned.""" data = { - 'name': 'example_name', - 'width': 1, - 'image': invalid_type_image, + "name": "example_name", + "width": 1, + "image": invalid_type_image, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for the active flag parameter. - """ + """Tests for the active flag parameter.""" - @pytest.mark.parametrize('active_flag', [True, False, None]) + @staticmethod + @pytest.mark.parametrize( + argnames="active_flag", + argvalues=[True, False, None], + ) def test_valid( - self, - active_flag: Union[bool, None], + *, + active_flag: bool | None, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: - """ - Boolean values and NULL are valid active flags. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - content_type = 'application/json' + """Boolean values and NULL are valid active flags.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + content_type = "application/json" data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - 'active_flag': active_flag, + "name": "example", + "width": 1, + "image": image_data_encoded, + "active_flag": active_flag, } - response = add_target_to_vws( - vuforia_database=vuforia_database, + response = _add_target_to_vws( + vws_client=vws_client, data=data, content_type=content_type, ) assert_success(response=response) + @staticmethod def test_invalid( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: """ - Values which are not Boolean values or NULL are not valid active flags. + Values which are not Boolean values or NULL are not valid active + flags. """ - active_flag = 'string' - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - content_type = 'application/json' + active_flag = "string" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + content_type = "application/json" data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - 'active_flag': active_flag, + "name": "example", + "width": 1, + "image": image_data_encoded, + "active_flag": active_flag, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type=content_type, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type=content_type, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_not_set( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - The active flag defaults to True if it is not set. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """The active flag defaults to True if it is not set.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'my_example_name', - 'width': 1234, - 'image': image_data_encoded, + "name": "my_example_name", + "width": 1234, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + response = _add_target_to_vws( + vws_client=vws_client, data=data, content_type="application/json" ) - - target_id = response.json()['target_id'] + response_json = json.loads(s=response.text) + target_id = response_json["target_id"] target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.active_flag is True + @staticmethod def test_set_to_none( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - The active flag defaults to True if it is set to NULL. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """The active flag defaults to True if it is set to NULL.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'my_example_name', - 'width': 1234, - 'image': image_data_encoded, - 'active_flag': None, + "name": "my_example_name", + "width": 1234, + "image": image_data_encoded, + "active_flag": None, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + response = _add_target_to_vws( + vws_client=vws_client, data=data, content_type="application/json" ) - target_id = response.json()['target_id'] + response_json = json.loads(s=response.text) + target_id = response_json["target_id"] target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.active_flag is True -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUnexpectedData: """ - Tests for passing data which is not mandatory or allowed to the endpoint. + Tests for passing data which is not mandatory or allowed to the + endpoint. """ + @staticmethod def test_invalid_extra_data( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - A `BAD_REQUEST` response is returned when unexpected data is given. + A `BAD_REQUEST` response is returned when unexpected data is + given. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'extra_thing': 1, + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "extra_thing": 1, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestApplicationMetadata: - """ - Tests for the application metadata parameter. - """ - - _MAX_METADATA_BYTES = 1024 * 1024 - 1 + """Tests for the application metadata parameter.""" + @staticmethod @pytest.mark.parametrize( - 'metadata', - [ - b'a', - b'a' * _MAX_METADATA_BYTES, + argnames="metadata", + argvalues=[ + b"a", + b"a" * _MAX_METADATA_BYTES, ], - ids=['Short', 'Max length'], + ids=["Short", "Max length"], ) def test_base64_encoded( - self, - vuforia_database: VuforiaDatabase, + *, image_file_failed_state: io.BytesIO, metadata: bytes, + vws_client: VWS, ) -> None: - """ - A base64 encoded string is valid application metadata. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - metadata_encoded = base64.b64encode(metadata).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': metadata_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + """A base64 encoded string is valid application metadata.""" + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" ) - assert_success(response=response) + vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=metadata_encoded, + active_flag=True, + ) + @staticmethod def test_null( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - NULL is valid application metadata. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """NULL is valid application metadata.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': None, + request_data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "application_metadata": None, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + response = _add_target_to_vws( + vws_client=vws_client, + data=request_data, + content_type="application/json", ) assert_success(response=response) + @staticmethod def test_invalid_type( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ Values which are not a string or NULL are not valid application metadata. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': 1, + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "application_metadata": 1, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, high_quality_image: io.BytesIO, not_base64_encoded_processable: str, + vws_client: VWS, ) -> None: """ - Some strings which are not valid base64 encoded strings are allowed as + Some strings which are not valid base64 encoded strings are + allowed as application metadata. """ - image_content = high_quality_image.getvalue() - image_data_encoded = base64.b64encode(image_content).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': not_base64_encoded_processable, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=not_base64_encoded_processable, + active_flag=True, ) - assert_success(response=response) - + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, high_quality_image: io.BytesIO, not_base64_encoded_not_processable: str, + vws_client: VWS, ) -> None: """ - Some strings which are not valid base64 encoded strings are not allowed + Some strings which are not valid base64 encoded strings are not + allowed as application metadata. """ - image_content = high_quality_image.getvalue() - image_data_encoded = base64.b64encode(image_content).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': not_base64_encoded_not_processable, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=not_base64_encoded_not_processable, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) + @staticmethod def test_metadata_too_large( - self, - vuforia_database: VuforiaDatabase, + *, image_file_failed_state: io.BytesIO, + vws_client: VWS, ) -> None: """ - A base64 encoded string of greater than 1024 * 1024 bytes is too large + A base64 encoded string of greater than 1024 * 1024 bytes is too + large for application metadata. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - metadata = b'a' * (self._MAX_METADATA_BYTES + 1) - metadata_encoded = base64.b64encode(metadata).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': metadata_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + metadata = b"a" * (_MAX_METADATA_BYTES + 1) + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" ) + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: + vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=metadata_encoded, + active_flag=True, + ) + assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.METADATA_TOO_LARGE, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" + @staticmethod def test_inactive_project( - self, - inactive_database: VuforiaDatabase, + *, image_file_failed_state: io.BytesIO, + inactive_vws_client: VWS, ) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=inactive_database, - data=data, - content_type='application/json', - ) + with pytest.raises(expected_exception=ProjectInactiveError) as exc: + inactive_vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.PROJECT_INACTIVE, ) diff --git a/tests/mock_vws/test_authorization_header.py b/tests/mock_vws/test_authorization_header.py index 2678ab08e..2f9367f62 100644 --- a/tests/mock_vws/test_authorization_header.py +++ b/tests/mock_vws/test_authorization_header.py @@ -1,67 +1,76 @@ -""" -Tests for the `Authorization` header. -""" +"""Tests for the `Authorization` header.""" import io +import json import uuid from http import HTTPStatus -from typing import Dict from urllib.parse import urlparse import pytest -import requests -from requests.structures import CaseInsensitiveDict from vws import VWS, CloudRecoService -from vws.exceptions import AuthenticationFailure, Fail +from vws.exceptions import cloud_reco_exceptions +from vws.exceptions.vws_exceptions import AuthenticationFailureError, FailError from vws_auth_tools import rfc_1123_date from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( - assert_valid_date_header, assert_valid_transaction_id, assert_vwq_failure, assert_vws_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestAuthorizationHeader: """ - Tests for what happens when the `Authorization` header is not as expected. + Tests for what happens when the `Authorization` header is not as + expected. """ - def test_missing(self, endpoint: Endpoint) -> None: + @staticmethod + def test_missing(endpoint: Endpoint) -> None: """ - An `UNAUTHORIZED` response is returned when no `Authorization` header + An `UNAUTHORIZED` response is returned when no `Authorization` + header is given. """ date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - - headers: Dict[str, str] = { - **endpoint_headers, - 'Date': date, + new_headers: dict[str, str] = { + **endpoint.headers, + "Date": date, } + new_headers.pop("Authorization", None) + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) - headers.pop('Authorization', None) + response = new_endpoint.send() - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, - ) + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='text/plain; charset=ISO-8859-1', + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", ) - assert response.text == 'Authorization header missing.' + assert response.text == "Authorization header missing." return assert_vws_failure( @@ -71,49 +80,57 @@ def test_missing(self, endpoint: Endpoint) -> None: ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMalformed: - """ - Tests for passing a malformed ``Authorization`` header. - """ + """Tests for passing a malformed ``Authorization`` header.""" - @pytest.mark.parametrize( - 'authorization_string', - ['gibberish', 'VWS', 'VWS '], - ) - def test_one_part( - self, - endpoint: Endpoint, - authorization_string: str, - ) -> None: - """ - A valid authorization string is two "parts" when split on a space. When + @staticmethod + def test_one_part_no_space(endpoint: Endpoint) -> None: + """A valid authorization string is two "parts" when split on a + space. + + When a string is given which is one "part", a ``BAD_REQUEST`` or ``UNAUTHORIZED`` response is returned. """ date = rfc_1123_date() - headers: Dict[str, str] = { - **endpoint.prepared_request.headers, - 'Authorization': authorization_string, - 'Date': date, + # We use "VWS" as this is the first part of a valid authorization + # string, but really any string which is not two parts when split on a + # space will do. + authorization_string = "VWS" + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + response = new_endpoint.send() + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='text/plain; charset=ISO-8859-1', + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", ) - assert response.text == 'Malformed authorization header.' + assert response.text == "Malformed authorization header." return assert_vws_failure( @@ -122,47 +139,98 @@ def test_one_part( result_code=ResultCodes.FAIL, ) - @pytest.mark.parametrize( - 'authorization_string', - [ - 'VWS foobar:', - 'VWS foobar', - ], - ) - def test_missing_signature( - self, - endpoint: Endpoint, - authorization_string: str, - ) -> None: + @staticmethod + def test_one_part_with_space(endpoint: Endpoint) -> None: + """A valid authorization string is two "parts" when split on a + space. + + When + a string is given which is one "part", a ``BAD_REQUEST`` or + ``UNAUTHORIZED`` response is returned. + """ + authorization_string = "VWS " + date = rfc_1123_date() + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", + ) + assert response.text == "Malformed authorization header." + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + result_code=ResultCodes.FAIL, + ) + + @staticmethod + def test_missing_signature(endpoint: Endpoint) -> None: """ If a signature is missing `Authorization` header is given, a ``BAD_REQUEST`` response is given. """ date = rfc_1123_date() - headers: Dict[str, str] = { - **endpoint.prepared_request.headers, - 'Authorization': authorization_string, - 'Date': date, + authorization_string = "VWS foobar:" + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + response = new_endpoint.send() + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - content_type='text/html; charset=ISO-8859-1', + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", ) - # We have seen multiple responses given. - assert 'Powered by Jetty' in response.text - assert '500 Server Error' in response.text + assert response.text == "Malformed authorization header." return assert_vws_failure( @@ -172,33 +240,33 @@ def test_missing_signature( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestBadKey: - """ - Tests for making requests with incorrect keys. - """ + """Tests for making requests with incorrect keys.""" + @staticmethod def test_bad_access_key_services( - self, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - If the server access key given does not match any database, a ``Fail`` + If the server access key given does not match any database, a + ``Fail`` response is returned. """ vws_client = VWS( - server_access_key='example', + server_access_key="example", server_secret_key=vuforia_database.server_secret_key, ) - with pytest.raises(Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.get_target_record(target_id=uuid.uuid4().hex) assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + @staticmethod def test_bad_access_key_query( - self, - vuforia_database: VuforiaDatabase, + *, + vuforia_database: CloudDatabase, high_quality_image: io.BytesIO, ) -> None: """ @@ -206,11 +274,13 @@ def test_bad_access_key_query( response is returned. """ cloud_reco_client = CloudRecoService( - client_access_key='example', + client_access_key="example", client_secret_key=vuforia_database.client_secret_key, ) - with pytest.raises(AuthenticationFailure) as exc: + with pytest.raises( + expected_exception=cloud_reco_exceptions.AuthenticationFailureError + ) as exc: cloud_reco_client.query(image=high_quality_image) response = exc.value.response @@ -218,43 +288,49 @@ def test_bad_access_key_query( assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate="VWS", + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] + result_code = json.loads(s=response.text)["result_code"] + transaction_id = json.loads(s=response.text)["transaction_id"] assert result_code == ResultCodes.AUTHENTICATION_FAILURE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id":' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" ) assert response.text == expected_text + @staticmethod def test_bad_secret_key_services( - self, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ If the server secret key given is incorrect, an - ``AuthenticationFailure`` response is returned. + ``AuthenticationFailureError`` response is returned. """ vws_client = VWS( server_access_key=vuforia_database.server_access_key, - server_secret_key='example', + server_secret_key="example", ) - with pytest.raises(AuthenticationFailure): + with pytest.raises(expected_exception=AuthenticationFailureError): vws_client.get_target_record(target_id=uuid.uuid4().hex) + @staticmethod def test_bad_secret_key_query( - self, - vuforia_database: VuforiaDatabase, + *, + vuforia_database: CloudDatabase, high_quality_image: io.BytesIO, ) -> None: """ @@ -263,10 +339,12 @@ def test_bad_secret_key_query( """ cloud_reco_client = CloudRecoService( client_access_key=vuforia_database.client_access_key, - client_secret_key='example', + client_secret_key="example", ) - with pytest.raises(AuthenticationFailure) as exc: + with pytest.raises( + expected_exception=cloud_reco_exceptions.AuthenticationFailureError + ) as exc: cloud_reco_client.query(image=high_quality_image) response = exc.value.response @@ -274,20 +352,25 @@ def test_bad_secret_key_query( assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate="VWS", + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] + result_code = json.loads(s=response.text)["result_code"] + transaction_id = json.loads(s=response.text)["transaction_id"] assert result_code == ResultCodes.AUTHENTICATION_FAILURE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id":' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" ) assert response.text == expected_text diff --git a/tests/mock_vws/test_cloud_query_failure_response.py b/tests/mock_vws/test_cloud_query_failure_response.py new file mode 100644 index 000000000..f45852356 --- /dev/null +++ b/tests/mock_vws/test_cloud_query_failure_response.py @@ -0,0 +1,132 @@ +"""Tests for configurable Cloud Query failure responses.""" + +import io +from collections.abc import Callable +from http import HTTPMethod, HTTPStatus + +import httpx +import pytest +import requests +from urllib3.filepost import encode_multipart_formdata +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws import CloudQueryFailureResponse, MockVWS +from mock_vws.database import CloudDatabase + +_QUERY_URL = "https://cloudreco.vuforia.com/v1/query" +type _HTTPResponse = requests.Response | httpx.Response +type _QuerySender = Callable[[dict[str, str], bytes], _HTTPResponse] + + +def _requests_query(headers: dict[str, str], body: bytes) -> _HTTPResponse: + """Send a Cloud Query request with ``requests``.""" + return requests.post( + url=_QUERY_URL, + headers=headers, + data=body, + timeout=30, + ) + + +def _httpx_query(headers: dict[str, str], body: bytes) -> _HTTPResponse: + """Send a Cloud Query request with ``httpx``.""" + return httpx.post( + url=_QUERY_URL, + headers=headers, + content=body, + timeout=30, + ) + + +def _valid_query( + *, + database: CloudDatabase, + image: io.BytesIO, +) -> tuple[dict[str, str], bytes]: + """Build an otherwise-valid, signed Cloud Query request.""" + request_path = "/v1/query" + body, content_type = encode_multipart_formdata( + fields={ + "image": ("image.jpeg", image.getvalue(), "image/jpeg"), + } + ) + date = rfc_1123_date() + authorization = authorization_header( + access_key=database.client_access_key, + secret_key=database.client_secret_key, + method=HTTPMethod.POST, + content=body, + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + headers = { + "Authorization": authorization, + "Content-Type": content_type, + "Date": date, + } + return headers, body + + +@pytest.mark.parametrize( + argnames="send_query", + argvalues=[_requests_query, _httpx_query], + ids=["requests", "httpx"], +) +@pytest.mark.parametrize( + argnames=("status_code", "headers", "body", "expected_body"), + argvalues=[ + ( + HTTPStatus.BAD_REQUEST, + {"Content-Length": "0", "X-Query-Failure": "empty"}, + b"", + b"", + ), + ( + HTTPStatus.TOO_MANY_REQUESTS, + { + "Content-Type": "text/plain; charset=utf-8", + "Retry-After": "10", + "X-Query-Failure": "text", + }, + "Temporarily unavailable — retry later", + "Temporarily unavailable — retry later".encode(), + ), + ( + HTTPStatus.SERVICE_UNAVAILABLE, + {"Content-Type": "application/octet-stream"}, + b"\xffupstream failure", + b"\xffupstream failure", + ), + ], + ids=["empty-4xx", "text-4xx", "raw-5xx"], +) +def test_configured_failure_response( + *, + high_quality_image: io.BytesIO, + send_query: _QuerySender, + status_code: HTTPStatus, + headers: dict[str, str], + body: str | bytes, + expected_body: bytes, +) -> None: + """Both in-process backends preserve the configured response.""" + database = CloudDatabase() + query_headers, query_body = _valid_query( + database=database, + image=high_quality_image, + ) + failure = CloudQueryFailureResponse( + status_code=status_code, + headers=headers, + body=body, + ) + + with MockVWS(cloud_query_failure_response=failure) as mock: + mock.add_cloud_database(cloud_database=database) + response = send_query(query_headers, query_body) + + assert response.status_code == status_code + assert response.content == expected_body + for name, value in headers.items(): + assert response.headers[name] == value diff --git a/tests/mock_vws/test_content_length.py b/tests/mock_vws/test_content_length.py index bcd826fbe..41e34fe24 100644 --- a/tests/mock_vws/test_content_length.py +++ b/tests/mock_vws/test_content_length.py @@ -1,106 +1,192 @@ -""" -Tests for the ``Content-Length`` header. -""" +"""Tests for the ``Content-Length`` header.""" +import textwrap from http import HTTPStatus from urllib.parse import urlparse import pytest -import requests -from requests.structures import CaseInsensitiveDict from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( + assert_valid_date_header, assert_vwq_failure, assert_vws_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestIncorrect: - """ - Tests for the ``Content-Length`` header set incorrectly. + """Tests for the ``Content-Length`` header set incorrectly. We cannot test what happens if ``Content-Length`` is removed from a prepared request because ``requests-mock`` behaves differently to ``requests`` - https://github.com/jamielennox/requests-mock/issues/80. """ - def test_not_integer(self, endpoint: Endpoint) -> None: + @staticmethod + def test_not_integer(endpoint: Endpoint) -> None: """ - A ``BAD_REQUEST`` error is given when the given ``Content-Length`` is + A ``BAD_REQUEST`` error is given when the given ``Content- + Length`` is not an integer. """ - endpoint_headers = dict(endpoint.prepared_request.headers) - if not endpoint_headers.get('Content-Type'): + if not endpoint.headers.get("Content-Type"): return - content_length = '0.4' - headers = {**endpoint_headers, 'Content-Length': content_length} - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, - ) + content_length = "0.4" - assert response.text == '' - assert response.headers == { - 'Content-Length': '0', - 'Connection': 'Close', + new_headers = { + **endpoint.headers, + "Content-Length": content_length, } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) assert response.status_code == HTTPStatus.BAD_REQUEST - def test_too_large(self, endpoint: Endpoint) -> None: - """ - A ``GATEWAY_TIMEOUT`` is given if the given content length is too - large. - """ - endpoint_headers = dict(endpoint.prepared_request.headers) - if not endpoint_headers.get('Content-Type'): + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + assert not response.text + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "Close", + } return - content_length = str(int(endpoint_headers['Content-Length']) + 1) - headers = {**endpoint_headers, 'Content-Length': content_length} + assert_valid_date_header(response=response) + expected_response_text = textwrap.dedent( + text="""\ + \r + 400 Bad Request\r + \r +

400 Bad Request

\r + \r + \r + """, + ) + assert response.text == expected_response_text + expected_headers = { + "Content-Length": str(object=len(response.text)), + "Content-Type": "text/html", + "Connection": "close", + "Server": "awselb/2.0", + "Date": response.headers["Date"], + } + assert response.headers == expected_headers + + @staticmethod + @pytest.mark.skip(reason="It takes too long to run this test.") + def test_too_large(endpoint: Endpoint) -> None: # pragma: no cover + """An error is given if the given content length is too large.""" + if not endpoint.headers.get("Content-Type"): + pytest.skip(reason="No Content-Type header for this request") + + netloc = urlparse(url=endpoint.base_url).netloc + content_length = str( + object=int(endpoint.headers["Content-Length"]) + 1 + ) + + new_headers = { + **endpoint.headers, + "Content-Length": content_length, + } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - assert response.text == '' - assert response.headers == { - 'Content-Length': '0', - 'Connection': 'keep-alive', + response = new_endpoint.send() + + # We do not use ``handle_server_errors`` here because we do not want to + # retry on the Gateway Timeout. + if netloc == "cloudreco.vuforia.com": + assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT + assert not response.text + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "keep-alive", + } + return + + handle_server_errors(response=response) + assert_valid_date_header(response=response) + # We have seen both of these response texts. + assert response.text in {"stream timeout", ""} + expected_headers = { + "Content-Length": str(object=len(response.text)), + "Connection": "close", + "Content-Type": "text/plain", + "server": "envoy", + "Date": response.headers["Date"], } - assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT + assert response.headers == expected_headers + assert response.status_code == HTTPStatus.REQUEST_TIMEOUT - def test_too_small(self, endpoint: Endpoint) -> None: + @staticmethod + def test_too_small(endpoint: Endpoint) -> None: """ - An ``UNAUTHORIZED`` response is given if the given content length is + An ``UNAUTHORIZED`` response is given if the given content + length is too small. """ - endpoint_headers = dict(endpoint.prepared_request.headers) - if not endpoint_headers.get('Content-Type'): + if not endpoint.headers.get("Content-Type"): return - content_length = str(int(endpoint_headers['Content-Length']) - 1) - headers = {**endpoint_headers, 'Content-Length': content_length} + real_content_length = len(endpoint.data) + content_length = real_content_length - 1 - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_headers = { + **endpoint.headers, + "Content-Length": str(object=content_length), + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + response = new_endpoint.send() + + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate="VWS", + connection="keep-alive", ) return diff --git a/tests/mock_vws/test_database_summary.py b/tests/mock_vws/test_database_summary.py index d8cb67350..8dce4fe8d 100644 --- a/tests/mock_vws/test_database_summary.py +++ b/tests/mock_vws/test_database_summary.py @@ -1,43 +1,57 @@ -""" -Tests for the mock of the database summary endpoint. -""" +"""Tests for the mock of the database summary endpoint.""" import io import logging import uuid from http import HTTPStatus -from time import sleep import pytest -import timeout_decorator +from beartype import beartype +from tenacity import RetryCallState, retry +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed from vws import VWS, CloudRecoService -from vws.exceptions import Fail +from vws.exceptions.vws_exceptions import FailError from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase -LOGGER = logging.getLogger(__name__) -LOGGER.setLevel(logging.DEBUG) +LOGGER = logging.getLogger(name=__name__) +LOGGER.setLevel(level=logging.DEBUG) -@timeout_decorator.timeout(seconds=500) +@beartype +def _log_attempt_number(retry_state: RetryCallState) -> None: + """Log the attempt number of a retry.""" + attempt_number: int = retry_state.attempt_number + message = f"Attempt number: {attempt_number}" + LOGGER.debug(msg=message) + + +@retry( + # We wait 0.2 seconds rather than less than that to decrease the number + # of calls made to the API, to decrease the likelihood of hitting the + # request quota. + wait=wait_fixed(wait=0.2), + # Wait up to 700 seconds (arbitrary, though we saw timeouts with 500 + # seconds) for the number of images in various categories to match the + # expected number. This is necessary because the database summary endpoint + # lags behind the real data. + stop=stop_after_delay(max_delay=700), + retry=retry_if_exception_type(exception_types=(AssertionError,)), + before=_log_attempt_number, +) def _wait_for_image_numbers( + *, vws_client: VWS, active_images: int, inactive_images: int, failed_images: int, processing_images: int, ) -> None: - """ - Wait up to 500 seconds (arbitrary, though we saw timeouts with 300 seconds) - for the number of images in various categories to match the expected - number. - - This is necessary because the database summary endpoint lags behind the - real data. - - This is susceptible to false positives because if, for example, we expect - no images, and the endpoint adds images with a delay, we will not know. + """Wait for the number of images in various categories of the database + summary to match the expected given numbers. Args: vws_client: The client to use to connect to Vuforia. @@ -47,59 +61,44 @@ def _wait_for_image_numbers( processing_images: The expected number of processing images. Raises: - TimeoutError: The numbers of images in various categories do not match + ValueError: The numbers of images in various categories do not match within the time limit. """ - requirements = { - 'active_images': active_images, - 'inactive_images': inactive_images, - 'failed_images': failed_images, - 'processing_images': processing_images, - } - - # If we wait for all requirements to match at the same time, - # we will often not reach that. - # We therefore wait for each requirement to match at least once. + database_summary_report = vws_client.get_database_summary_report() - # We wait 0.2 seconds rather than less than that to decrease the number - # of calls made to the API, to decrease the likelihood of hitting the - # request quota. - sleep_seconds = 0.2 - - for key, value in requirements.items(): - while True: - report = vws_client.get_database_summary_report() - relevant_images_in_summary = getattr(report, key) - if value != relevant_images_in_summary: # pragma: no cover - message = ( - f'Expected {value} `{key}`s. ' - f'Found {relevant_images_in_summary} `{key}`s.' - ) - LOGGER.debug(message) + expected = { + "active_images": active_images, + "inactive_images": inactive_images, + "failed_images": failed_images, + "processing_images": processing_images, + } - sleep(sleep_seconds) + actual = { + "active_images": database_summary_report.active_images, + "inactive_images": database_summary_report.inactive_images, + "failed_images": database_summary_report.failed_images, + "processing_images": database_summary_report.processing_images, + } - # This makes the entire test invalid. - # However, we have found that without this Vuforia is flaky. - # We have waited over 10 minutes for the summary to change and - # that is not sustainable in a test suite. - break + msg = f"Expected: {expected}. Actual: {actual}" + LOGGER.debug(msg=msg) + assert actual == expected -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDatabaseSummary: """ - Tests for the mock of the database summary endpoint at `GET /summary`. + Tests for the mock of the database summary endpoint at `GET + /summary`. """ + @staticmethod def test_success( - self, - vuforia_database: VuforiaDatabase, + *, + vuforia_database: CloudDatabase, vws_client: VWS, ) -> None: - """ - It is possible to get a success response. - """ + """It is possible to get a success response.""" report = vws_client.get_database_summary_report() assert report.name == vuforia_database.database_name @@ -111,16 +110,12 @@ def test_success( processing_images=0, ) - def test_active_images( - self, - vws_client: VWS, - target_id: str, - ) -> None: - """ - The number of images in the active state is returned. - """ - vws_client.wait_for_target_processed(target_id=target_id) - + @staticmethod + # ``verify_mock_vuforia`` is given here as well as on the class so + # that the backend is set up before ``target_id`` adds a target. + @pytest.mark.usefixtures("verify_mock_vuforia", "target_id") + def test_active_images(*, vws_client: VWS) -> None: + """The number of images in the active state is returned.""" _wait_for_image_numbers( vws_client=vws_client, active_images=1, @@ -129,14 +124,13 @@ def test_active_images( processing_images=0, ) + @staticmethod def test_failed_images( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - The number of images with a 'failed' status is returned. - """ + """The number of images with a 'failed' status is returned.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -155,13 +149,15 @@ def test_failed_images( processing_images=0, ) + @staticmethod def test_inactive_images( - self, + *, vws_client: VWS, image_file_success_state_low_rating: io.BytesIO, ) -> None: """ - The number of images with a False active_flag and a 'success' status is + The number of images with a False active_flag and a 'success' + status is returned. """ target_id = vws_client.add_target( @@ -182,14 +178,13 @@ def test_inactive_images( processing_images=0, ) + @staticmethod def test_inactive_failed( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - An image with a 'failed' status does not show as inactive. - """ + """An image with a 'failed' status does not show as inactive.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -208,14 +203,13 @@ def test_inactive_failed( processing_images=0, ) + @staticmethod def test_deleted( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - Deleted targets are not shown in the summary. - """ + """Deleted targets are not shown in the summary.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -237,31 +231,28 @@ def test_deleted( class TestProcessingImages: - """ - Tests for processing images. - - These tests are run only on the mock, and not the real implementation. + """Tests for processing images. - This is because the real implementation is not reliable. - This is a documented difference between the mock and the real + These tests are run only on the mock, and not the real implementation. + + This is because the real implementation is not reliable. This is a + documented difference between the mock and the real implementation. """ + @staticmethod def test_processing_images( - self, image_file_success_state_low_rating: io.BytesIO, ) -> None: - """ - The number of images in the processing state is returned. - """ - database = VuforiaDatabase() + """The number of images in the processing state is returned.""" + database = CloudDatabase() vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -279,41 +270,42 @@ def test_processing_images( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestQuotas: - """ - Tests for quotas and thresholds. - """ + """Tests for quotas and thresholds.""" + + @staticmethod + def test_quotas(vws_client: VWS) -> None: + """Quotas are included in the database summary. - def test_quotas(self, vws_client: VWS) -> None: - """ - Quotas are included in the database summary. These match the quotas given for a free license. """ report = vws_client.get_database_summary_report() - assert report.target_quota == 1000 - assert report.request_quota == 100000 - assert report.reco_threshold == 1000 + expected_target_quota = 1000 + expected_request_quota = 100000 + expected_reco_threshold = 1000 + assert report.target_quota == expected_target_quota + assert report.request_quota == expected_request_quota + assert report.reco_threshold == expected_reco_threshold -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestRecos: - """ - Tests for the recognition count fields. - """ + """Tests for the recognition count fields.""" + @staticmethod def test_query_request( - self, + *, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: - """ - The ``*_recos`` counts seem to be delayed by a significant amount of + """The ``*_recos`` counts seem to be delayed by a significant + amount of time. - We therefore test that they exist, are integers and do not change - between quick requests. + We therefore test that they exist, are integers and do not + change between quick requests. """ target_id = vws_client.add_target( name=uuid.uuid4().hex, @@ -339,18 +331,15 @@ def test_query_request( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestRequestUsage: - """ - Tests for the ``request_usage`` field. - """ + """Tests for the ``request_usage`` field.""" - def test_target_request( - self, - vws_client: VWS, - ) -> None: + @staticmethod + def test_target_request(vws_client: VWS) -> None: """ - The ``request_usage`` count does not increase with each request to the + The ``request_usage`` count does not increase with each request + to the target API. """ report = vws_client.get_database_summary_report() @@ -360,21 +349,23 @@ def test_target_request( new_request_usage = report.request_usage assert new_request_usage == original_request_usage + @staticmethod def test_bad_target_request( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: """ - The ``request_usage`` count does not increase with each request to the + The ``request_usage`` count does not increase with each request + to the target API, even if it is a bad request. """ report = vws_client.get_database_summary_report() original_request_usage = report.request_usage - with pytest.raises(Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.add_target( - name='example', + name="example", width=-1, image=high_quality_image, active_flag=True, @@ -387,14 +378,16 @@ def test_bad_target_request( new_request_usage = report.request_usage assert new_request_usage == original_request_usage + @staticmethod def test_query_request( - self, + *, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: """ - The ``request_usage`` count does not increase with each query. + The ``request_usage`` count does not increase with each + query. """ report = vws_client.get_database_summary_report() original_request_usage = report.request_usage @@ -406,17 +399,13 @@ def test_query_request( assert new_request_usage == original_request_usage -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" + @staticmethod def test_inactive_project( - self, inactive_vws_client: VWS, ) -> None: - """ - The project's active state does not affect the database summary. - """ + """The project's active state does not affect the database summary.""" inactive_vws_client.get_database_summary_report() diff --git a/tests/mock_vws/test_date_header.py b/tests/mock_vws/test_date_header.py index cafd2312c..0bf55ac4e 100644 --- a/tests/mock_vws/test_date_header.py +++ b/tests/mock_vws/test_date_header.py @@ -1,80 +1,85 @@ -""" -Tests for the `Date` header. -""" +"""Tests for the `Date` header.""" +import json from datetime import datetime, timedelta from http import HTTPStatus -from typing import Dict from urllib.parse import urlparse +from zoneinfo import ZoneInfo import pytest -import requests -from backports.zoneinfo import ZoneInfo from freezegun import freeze_time -from requests.structures import CaseInsensitiveDict from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( assert_query_success, + assert_valid_transaction_id, assert_vwq_failure, assert_vws_failure, assert_vws_response, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors _VWS_MAX_TIME_SKEW = timedelta(minutes=5) _VWQ_MAX_TIME_SKEW = timedelta(minutes=65) _LEEWAY = timedelta(seconds=10) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMissing: - """ - Tests for what happens when the `Date` header is missing. - """ + """Tests for what happens when the `Date` header is missing.""" - def test_no_date_header( - self, - endpoint: Endpoint, - ) -> None: + @staticmethod + def test_no_date_header(endpoint: Endpoint) -> None: """ - A `BAD_REQUEST` response is returned when no `Date` header is given. + A `BAD_REQUEST` response is returned when no `Date` header is + given. """ - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) - authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, - date='', - request_path=endpoint.prepared_request.path_url, + date="", + request_path=endpoint.path_url, ) - headers: Dict[str, str] = { - **endpoint_headers, - 'Authorization': authorization_string, + new_headers: dict[str, str] = { + **endpoint.headers, + "Authorization": authorization_string, } - headers.pop('Date', None) - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_headers.pop("Date", None) + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': - expected_content_type = 'text/plain; charset=ISO-8859-1' - assert response.text == 'Date header required.' + response = new_endpoint.send() + + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + + if netloc == "cloudreco.vuforia.com": + expected_content_type = "text/plain;charset=iso-8859-1" + assert response.text == "Date header required." assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, content_type=expected_content_type, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) return @@ -85,62 +90,67 @@ def test_no_date_header( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestFormat: """ - Tests for what happens when the `Date` header is not in the - expected format. + Tests for what happens when the `Date` header is not in the expected + format. """ - def test_incorrect_date_format( - self, - endpoint: Endpoint, - ) -> None: - """ - A `BAD_REQUEST` response is returned when the date given in the date + @staticmethod + def test_incorrect_date_format(endpoint: Endpoint) -> None: + """A `BAD_REQUEST` response is returned when the date given in the + date header is not in the expected format (RFC 1123) to VWS API. An `UNAUTHORIZED` response is returned to the VWQ API. """ - gmt = ZoneInfo('GMT') - with freeze_time(datetime.now(tz=gmt)): - now = datetime.now() - date_incorrect_format = now.strftime('%a %b %d %H:%M:%S') - - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) + gmt = ZoneInfo(key="GMT") + with freeze_time(time_to_freeze=datetime.now(tz=gmt)): + now = datetime.now(tz=gmt) + date_incorrect_format = now.strftime(format="%a %b %d %H:%M:%S") authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, date=date_incorrect_format, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date_incorrect_format, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date_incorrect_format, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) + response = new_endpoint.send() + + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': - assert response.text == 'Malformed date header.' + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + assert response.text == "Malformed date header." assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='text/plain; charset=ISO-8859-1', + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", ) return @@ -151,137 +161,302 @@ def test_incorrect_date_format( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestSkewedTime: """ Tests for what happens when the `Date` header is given with an - unexpected time. + unexpected + time. """ - @pytest.mark.parametrize( - 'time_multiplier', - [1, -1], - ids=(['After', 'Before']), - ) - def test_date_out_of_range( - self, - time_multiplier: int, - endpoint: Endpoint, - ) -> None: - """ - If the date header is more than five minutes (target API) or 65 minutes - (query API) before or after the request is sent, a `FORBIDDEN` response + @staticmethod + def test_date_out_of_range_after(endpoint: Endpoint) -> None: + """If the date header is more than five minutes (target API) or 65 + minutes (query API) after the request is sent, a `FORBIDDEN` + response is returned. - Because there is a small delay in sending requests and Vuforia isn't - consistent, some leeway is given. + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { - 'vws.vuforia.com': _VWS_MAX_TIME_SKEW, - 'cloudreco.vuforia.com': _VWQ_MAX_TIME_SKEW, + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, }[netloc] time_difference_from_now = skew + _LEEWAY - time_difference_from_now *= time_multiplier - gmt = ZoneInfo('GMT') - with freeze_time(datetime.now(tz=gmt) + time_difference_from_now): + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now + ): date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) - authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) + response = new_endpoint.send() + + handle_server_errors(response=response) + # Even with the query endpoint, we get a JSON response. + if netloc == "cloudreco.vuforia.com": + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + assert_vws_failure( response=response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.REQUEST_TIME_TOO_SKEWED, ) - @pytest.mark.parametrize( - 'time_multiplier', - [1, -1], - ids=(['After', 'Before']), - ) - def test_date_in_range( - self, - time_multiplier: int, - endpoint: Endpoint, - ) -> None: + @staticmethod + def test_date_out_of_range_before(endpoint: Endpoint) -> None: + """If the date header is more than five minutes (target API) or 65 + minutes (query API) before the request is sent, a `FORBIDDEN` + response + is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - If a date header is within five minutes before or after the request - is sent, no error is returned. + netloc = urlparse(url=endpoint.base_url).netloc + skew = { + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, + }[netloc] + time_difference_from_now = skew + _LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now + ): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + # Even with the query endpoint, we get a JSON response. + if netloc == "cloudreco.vuforia.com": + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return - Because there is a small delay in sending requests and Vuforia isn't - consistent, some leeway is given. + assert_vws_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + result_code=ResultCodes.REQUEST_TIME_TOO_SKEWED, + ) + + @staticmethod + def test_date_in_range_after(endpoint: Endpoint) -> None: + """If a date header is within five minutes after the request is + sent, + no error is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { - 'vws.vuforia.com': _VWS_MAX_TIME_SKEW, - 'cloudreco.vuforia.com': _VWQ_MAX_TIME_SKEW, + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, }[netloc] time_difference_from_now = skew - _LEEWAY - time_difference_from_now *= time_multiplier - gmt = ZoneInfo('GMT') - with freeze_time(datetime.now(tz=gmt) + time_difference_from_now): + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now + ): date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + assert_query_success(response=response) + return + + if endpoint.successful_headers_result_code is None: + assert ( + response.status_code == endpoint.successful_headers_status_code + ) + return + + assert_vws_response( + response=response, + status_code=endpoint.successful_headers_status_code, + result_code=endpoint.successful_headers_result_code, + ) + + @staticmethod + def test_date_in_range_before(endpoint: Endpoint) -> None: + """If a date header is within five minutes before the request is + sent, + no error is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. + """ + netloc = urlparse(url=endpoint.base_url).netloc + skew = { + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, + }[netloc] + time_difference_from_now = skew - _LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now + ): + date = rfc_1123_date() authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + response = new_endpoint.send() + + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_query_success(response=response) return + if endpoint.successful_headers_result_code is None: + assert ( + response.status_code == endpoint.successful_headers_status_code + ) + return + assert_vws_response( response=response, status_code=endpoint.successful_headers_status_code, diff --git a/tests/mock_vws/test_delete_target.py b/tests/mock_vws/test_delete_target.py index dc55c9a17..0d184a08e 100644 --- a/tests/mock_vws/test_delete_target.py +++ b/tests/mock_vws/test_delete_target.py @@ -1,34 +1,31 @@ -""" -Tests for deleting targets. -""" +"""Tests for deleting targets.""" from http import HTTPStatus import pytest from vws import VWS -from vws.exceptions import ( - ProjectInactive, - TargetStatusProcessing, - UnknownTarget, +from vws.exceptions.vws_exceptions import ( + ProjectInactiveError, + TargetStatusProcessingError, + UnknownTargetError, ) from mock_vws._constants import ResultCodes from tests.mock_vws.utils.assertions import assert_vws_failure -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDelete: - """ - Tests for deleting targets. - """ + """Tests for deleting targets.""" + @staticmethod def test_no_wait( - self, - target_id: str, + *, + unprocessed_target_id: str, vws_client: VWS, ) -> None: - """ - When attempting to delete a target immediately after creating it, a + """When attempting to delete a target immediately after creating + it, a `FORBIDDEN` response is returned. This is because the target goes into a processing state. @@ -36,8 +33,10 @@ def test_no_wait( There is a race condition here - if the target goes into a success or fail state before the deletion attempt. """ - with pytest.raises(TargetStatusProcessing) as exc: - vws_client.delete_target(target_id=target_id) + with pytest.raises( + expected_exception=TargetStatusProcessingError + ) as exc: + vws_client.delete_target(target_id=unprocessed_target_id) assert_vws_failure( response=exc.value.response, @@ -45,36 +44,27 @@ def test_no_wait( result_code=ResultCodes.TARGET_STATUS_PROCESSING, ) - def test_processed( - self, - target_id: str, - vws_client: VWS, - ) -> None: - """ - When a target has finished processing, it can be deleted. - """ - vws_client.wait_for_target_processed(target_id=target_id) + @staticmethod + def test_processed(*, target_id: str, vws_client: VWS) -> None: + """When a target has finished processing, it can be deleted.""" vws_client.delete_target(target_id=target_id) - with pytest.raises(UnknownTarget): + with pytest.raises(expected_exception=UnknownTargetError): vws_client.get_target_record(target_id=target_id) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - target_id = 'abc12345a' - with pytest.raises(ProjectInactive) as exc: + target_id = "abc12345a" + with pytest.raises(expected_exception=ProjectInactiveError) as exc: inactive_vws_client.delete_target(target_id=target_id) assert_vws_failure( diff --git a/tests/mock_vws/test_docker.py b/tests/mock_vws/test_docker.py new file mode 100644 index 000000000..8b05e939c --- /dev/null +++ b/tests/mock_vws/test_docker.py @@ -0,0 +1,394 @@ +"""Tests for running the mock server in Docker.""" + +import io +import uuid +import zipfile +from collections.abc import Iterable, Iterator +from http import HTTPStatus +from typing import TYPE_CHECKING + +import docker +import pytest +import requests +from beartype import beartype +from docker.errors import BuildError, NotFound +from docker.models.containers import Container +from docker.models.networks import Network +from tenacity import retry +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed +from vws import VWS, CloudRecoService + +from mock_vws.database import CloudDatabase + +if TYPE_CHECKING: + from docker.models.images import Image + + +@retry( + wait=wait_fixed(wait=0.5), + stop=stop_after_delay(max_delay=20), + retry=retry_if_exception_type( + exception_types=(requests.exceptions.ConnectionError, ValueError), + ), + reraise=True, +) +@beartype +def _poll_health_check(container: Container) -> None: + """Poll a container until it reports a healthy status.""" + container.reload() + health_status = container.attrs["State"]["Health"]["Status"] + # In theory this might not be hit by coverage. + # Let's keep it required by coverage for now. + if health_status != "healthy": + error_message = ( + f"Container {container.name} is not healthy: {health_status}" + ) + raise ValueError(error_message) + + +@beartype +def wait_for_health_check(container: Container) -> None: + """Wait for a container to pass its health check. + + On failure, augment the error with the container's logs and the + Docker health check probe history so CI failures are easier to diagnose. + """ + try: + _poll_health_check(container=container) + except ValueError as exc: # pragma: no cover + container.reload() + logs = container.logs().decode(errors="replace") + health_log = container.attrs["State"]["Health"].get("Log", []) + probes = "\n".join( + f" exit={entry.get('ExitCode')!r} " + f"start={entry.get('Start')!r} end={entry.get('End')!r}\n" + f" output={entry.get('Output')!r}" + for entry in health_log + ) + error_message = ( + f"{exc}\n" + f"--- container logs ({container.name}) ---\n" + f"{logs}\n" + f"--- healthcheck probes ({container.name}) ---\n" + f"{probes}" + ) + raise ValueError(error_message) from exc + + +@retry( + wait=wait_fixed(wait=0.5), + stop=stop_after_delay(max_delay=60), + retry=retry_if_exception_type(exception_types=(ValueError,)), + reraise=True, +) +@beartype +def _wait_for_model_target_dataset_done( + *, + base_vws_url: str, + dataset_uuid: str, + access_token: str, +) -> None: + """Poll a Model Target dataset until it finishes processing.""" + response = requests.get( + url=f"{base_vws_url}/modeltargets/datasets/{dataset_uuid}/status", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + assert response.status_code == HTTPStatus.OK + status = response.json()["status"] + if status != "done": + error_message = f"Dataset {dataset_uuid} status is {status!r}." + raise ValueError(error_message) + + +@beartype +def _vws_base_url(*, vws_container: Container) -> str: + """Return the host-reachable base URL of the VWS container. + + The container publishes its port to an ephemeral host port, so this + must be re-read after a container restart. + """ + vws_container.reload() + port_attrs = vws_container.attrs["NetworkSettings"]["Ports"] + host_ip = port_attrs["5000/tcp"][0]["HostIp"] + host_port = port_attrs["5000/tcp"][0]["HostPort"] + return f"http://{host_ip}:{host_port}" + + +@pytest.fixture(name="custom_bridge_network") +def fixture_custom_bridge_network() -> Iterator[Network]: + """Yield a custom bridge network which containers can connect to. + + This also cleans up all containers connected to the network and the network + after the test. + + Yields: + A custom bridge network. + """ + client = docker.from_env() + name = "test-vws-bridge-" + uuid.uuid4().hex + try: + network = client.networks.create(name=name, driver="bridge") + except NotFound: + # On Windows the "bridge" network driver is not available and we use + # the "nat" driver instead. + network = client.networks.create(name=name, driver="nat") + + try: + yield network + finally: + network.reload() + images_to_remove: Iterable[Image] = set() + for container in network.containers: + network.disconnect(container=container) + container.stop() + container.remove(v=True, force=True) + assert container.image is not None + images_to_remove = {*images_to_remove, container.image} + + # This does leave behind untagged images. + for image in images_to_remove: + image.remove(force=True) + network.remove() + + +@pytest.mark.requires_docker_build +def test_build_and_run( + *, + high_quality_image: io.BytesIO, + custom_bridge_network: Network, + request: pytest.FixtureRequest, +) -> None: + """ + It is possible to build Docker images which combine to make a + working mock + application. + """ + repository_root = request.config.rootpath + client = docker.from_env() + + dockerfile = repository_root / "src/mock_vws/_flask_server/Dockerfile" + + random = uuid.uuid4().hex + target_manager_tag = f"vws-mock-target-manager:latest-{random}" + vws_tag = f"vws-mock-vws:latest-{random}" + vwq_tag = f"vws-mock-vwq:latest-{random}" + + try: + target_manager_image, _ = client.images.build( + path=str(object=repository_root), + dockerfile=str(object=dockerfile), + tag=target_manager_tag, + target="target-manager", + rm=True, + ) + except BuildError as exc: + full_log = "\n".join( + [item["stream"] for item in exc.build_log if "stream" in item], + ) + windows_message_substrings = ( + "no matching manifest for windows/amd64", + "no matching manifest for windows(10.0.26100)/amd64", + ) + # If this assertion fails, it may be useful to look at the other + # properties of ``exc``. + is_windows_container_error = any( + windows_message_substring in exc.msg + for windows_message_substring in windows_message_substrings + ) + assert is_windows_container_error, full_log + pytest.skip( + reason="We do not currently support using Windows containers." + ) + + vwq_image, _ = client.images.build( + path=str(object=repository_root), + dockerfile=str(object=dockerfile), + tag=vwq_tag, + target="vwq", + rm=True, + ) + + vws_image, _ = client.images.build( + path=str(object=repository_root), + dockerfile=str(object=dockerfile), + tag=vws_tag, + target="vws", + rm=True, + ) + + database = CloudDatabase() + target_manager_container_name = "vws-mock-target-manager-" + random + target_manager_internal_base_url = ( + f"http://{target_manager_container_name}:5000" + ) + + target_manager_container = client.containers.run( + image=target_manager_image, + detach=True, + name=target_manager_container_name, + publish_all_ports=True, + network=custom_bridge_network.name, + ) + vws_container = client.containers.run( + image=vws_image, + detach=True, + name="vws-mock-vws-" + random, + publish_all_ports=True, + network=custom_bridge_network.name, + environment={ + "TARGET_MANAGER_BASE_URL": target_manager_internal_base_url, + }, + ) + vwq_container = client.containers.run( + image=vwq_image, + detach=True, + name="vws-mock-vwq-" + random, + publish_all_ports=True, + network=custom_bridge_network.name, + environment={ + "TARGET_MANAGER_BASE_URL": target_manager_internal_base_url, + }, + ) + + for container in (target_manager_container, vws_container, vwq_container): + wait_for_health_check(container=container) + container.reload() + + target_manager_port_attrs = target_manager_container.attrs[ + "NetworkSettings" + ]["Ports"] + target_manager_port_attrs = target_manager_container.attrs[ + "NetworkSettings" + ]["Ports"] + target_manager_host_ip = target_manager_port_attrs["5000/tcp"][0]["HostIp"] + target_manager_host_port = target_manager_port_attrs["5000/tcp"][0][ + "HostPort" + ] + + vwq_port_attrs = vwq_container.attrs["NetworkSettings"]["Ports"] + vwq_host_ip = vwq_port_attrs["5000/tcp"][0]["HostIp"] + vwq_host_port = vwq_port_attrs["5000/tcp"][0]["HostPort"] + + base_vws_url = _vws_base_url(vws_container=vws_container) + base_vwq_url = f"http://{vwq_host_ip}:{vwq_host_port}" + base_target_manager_url = ( + f"http://{target_manager_host_ip}:{target_manager_host_port}" + ) + + response = requests.post( + url=f"{base_target_manager_url}/cloud_databases", + json=database.to_dict(), + timeout=30, + ) + + assert response.status_code == HTTPStatus.CREATED + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + ) + + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + base_vwq_url=base_vwq_url, + ) + + matching_targets = cloud_reco_client.query(image=high_quality_image) + + assert matching_targets[0].target_id == target_id + + _assert_model_target_round_trip(vws_container=vws_container) + + +@beartype +def _assert_model_target_round_trip(*, vws_container: Container) -> None: + """Create a Model Target dataset in one request, poll its status in + others, then download the generated dataset. + + The VWS container is restarted after the dataset is created: datasets + are stored in the target manager container, so they must survive a + restart of the VWS container. + """ + base_vws_url = _vws_base_url(vws_container=vws_container) + oauth_response = requests.post( + url=f"{base_vws_url}/oauth2/token", + auth=("client-id", "client-secret"), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + assert oauth_response.status_code == HTTPStatus.OK + access_token = oauth_response.json()["access_token"] + + dataset_request = { + "name": "example-dataset", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], + } + create_dataset_response = requests.post( + url=f"{base_vws_url}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=dataset_request, + timeout=30, + ) + assert create_dataset_response.status_code == HTTPStatus.CREATED + dataset_uuid = create_dataset_response.json()["uuid"] + + _wait_for_model_target_dataset_done( + base_vws_url=base_vws_url, + dataset_uuid=dataset_uuid, + access_token=access_token, + ) + + # The dataset is stored in the target manager container, so a restart + # of the VWS container must not lose it. + vws_container.restart() + wait_for_health_check(container=vws_container) + base_vws_url = _vws_base_url(vws_container=vws_container) + + status_response = requests.get( + url=f"{base_vws_url}/modeltargets/datasets/{dataset_uuid}/status", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + assert status_response.status_code == HTTPStatus.OK + assert status_response.json()["status"] == "done" + + download_response = requests.get( + url=f"{base_vws_url}/modeltargets/datasets/{dataset_uuid}/dataset", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + assert download_response.status_code == HTTPStatus.OK + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=download_response.content), + ) as downloaded_zip: + assert downloaded_zip.namelist() == ["dataset.json"] diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py new file mode 100644 index 000000000..f69e97c6e --- /dev/null +++ b/tests/mock_vws/test_flask_app_usage.py @@ -0,0 +1,1147 @@ +"""Tests for the usage of the mock Flask application.""" + +import email.utils +import io +import json +import time +import uuid +import zipfile +from collections.abc import Iterator +from http import HTTPMethod, HTTPStatus +from typing import Any + +import pytest +import requests +import responses +from PIL import Image +from requests_mock_flask import add_flask_app_to_mock +from vws import VWS, CloudRecoService +from vws.exceptions.vws_exceptions import ( + RequestQuotaReachedError, + TargetQuotaReachedError, + TooManyRequestsError, +) +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws._constants import ResultCodes +from mock_vws._flask_server.target_manager import ( + TARGET_MANAGER, + TARGET_MANAGER_FLASK_APP, +) +from mock_vws._flask_server.vwq import CLOUDRECO_FLASK_APP +from mock_vws._flask_server.vws import VWS_FLASK_APP +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.model_target import ( + ModelTargetDataset, + ModelTargetDatasetType, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) +from mock_vws.request_rate_limits import RequestRateLimit, RequestRateLimits +from mock_vws.target import VuMarkTarget +from tests.mock_vws.utils.usage_test_helpers import ( + processing_time_seconds, +) + +_EXAMPLE_URL_FOR_TARGET_MANAGER = "http://" + uuid.uuid4().hex + ".com" +_MODEL_TARGET_DATASET_REQUEST = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + + +@pytest.fixture(autouse=True) +def _(*, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Enable a mock service backed by the Flask applications.""" + with responses.RequestsMock( + assert_all_requests_are_fired=False, + ) as mock_obj: + add_flask_app_to_mock( + mock_obj=mock_obj, + flask_app=VWS_FLASK_APP, + base_url="https://vws.vuforia.com", + ) + + add_flask_app_to_mock( + mock_obj=mock_obj, + flask_app=CLOUDRECO_FLASK_APP, + base_url="https://cloudreco.vuforia.com", + ) + + add_flask_app_to_mock( + mock_obj=mock_obj, + flask_app=TARGET_MANAGER_FLASK_APP, + base_url=_EXAMPLE_URL_FOR_TARGET_MANAGER, + ) + + monkeypatch.setenv( + name="TARGET_MANAGER_BASE_URL", + value=_EXAMPLE_URL_FOR_TARGET_MANAGER, + ) + + yield + + for cloud_database in TARGET_MANAGER.cloud_databases: + TARGET_MANAGER.remove_cloud_database(cloud_database=cloud_database) + for vumark_database in TARGET_MANAGER.vumark_databases: + TARGET_MANAGER.remove_vumark_database(vumark_database=vumark_database) + for dataset_uuid in TARGET_MANAGER.model_target_datasets: + TARGET_MANAGER.remove_model_target_dataset(dataset_uuid=dataset_uuid) + + +class TestProcessingTime: + """Tests for the time taken to process targets in the mock.""" + + # There is a race condition in this test type - if tests start to + # fail, consider increasing the leeway. + LEEWAY = 1.0 + + def test_default( + self, + image_file_failed_state: io.BytesIO, + ) -> None: + """By default, targets in the mock takes 2 seconds to be processed.""" + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + time_taken = processing_time_seconds( + vuforia_database=database, + image=image_file_failed_state, + ) + + expected = 2 + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY + + def test_custom( + self, + *, + image_file_failed_state: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """It is possible to set a custom processing time.""" + seconds = 5.0 + monkeypatch.setenv( + name="PROCESSING_TIME_SECONDS", + value=str(object=seconds), + ) + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + time_taken = processing_time_seconds( + vuforia_database=database, + image=image_file_failed_state, + ) + + expected = seconds + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY + + +class TestRequestQuota: + """Tests for request quota exhaustion in the Flask mock.""" + + @staticmethod + def test_request_quota_reached() -> None: + """The Flask mock preserves and enforces a zero request quota.""" + database = CloudDatabase(request_quota=0) + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post( + url=databases_url, + json=database.to_dict(), + timeout=30, + ) + response.raise_for_status() + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=RequestQuotaReachedError): + client.list_targets() + + @staticmethod + def test_target_quota_reached( + *, + image_file_failed_state: io.BytesIO, + ) -> None: + """The Flask mock preserves and enforces a zero target quota.""" + database = CloudDatabase(target_quota=0) + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post( + url=databases_url, + json=database.to_dict(), + timeout=30, + ) + response.raise_for_status() + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=TargetQuotaReachedError): + client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + @staticmethod + def test_too_many_requests() -> None: + """The Flask mock preserves and enforces a zero request rate limit.""" + database = CloudDatabase(requests_per_second_limit=0) + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post( + url=databases_url, + json=database.to_dict(), + timeout=30, + ) + response.raise_for_status() + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=TooManyRequestsError): + client.list_targets() + + @staticmethod + def test_per_endpoint_limits() -> None: + """The Flask mock preserves and enforces per-endpoint limits.""" + database = CloudDatabase( + request_rate_limits=RequestRateLimits( + list_targets=RequestRateLimit( + max_requests=1, + window_seconds=60.0, + ), + ), + ) + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post( + url=databases_url, + json=database.to_dict(), + timeout=30, + ) + response.raise_for_status() + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + client.list_targets() + with pytest.raises(expected_exception=TooManyRequestsError): + client.list_targets() + + # Other endpoints are not limited. + client.get_database_summary_report() + + +class TestUnroutedRequests: + """Tests for requests which the Flask app does not route. + + Signed requests are covered by + ``tests/mock_vws/test_invalid_given_id.py``, which verifies the + responses against real Vuforia. + """ + + @staticmethod + def test_unauthenticated_unknown_path() -> None: + """A request to a path which is not routed returns a 404 even + without credentials. + + The Docker health check relies on this request returning a + response. + """ + response = VWS_FLASK_APP.test_client().get("/some-random-endpoint") + + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestAddCloudDatabase: + """Tests for adding cloud databases to the mock.""" + + @staticmethod + def test_duplicate_keys() -> None: + """ + It is not possible to have multiple cloud databases with + matching + keys. + """ + database = CloudDatabase( + server_access_key="1", + server_secret_key="2", + client_access_key="3", + client_secret_key="4", + database_name="5", + ) + + bad_server_access_key_db = CloudDatabase(server_access_key="1") + bad_server_secret_key_db = CloudDatabase(server_secret_key="2") + bad_client_access_key_db = CloudDatabase(client_access_key="3") + bad_client_secret_key_db = CloudDatabase(client_secret_key="4") + bad_database_name_db = CloudDatabase(database_name="5") + + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' + ) + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + client_access_key_conflict_error = ( + "All client access keys must be unique. " + 'There is already a database with the client access key "3".' + ) + client_secret_key_conflict_error = ( + "All client secret keys must be unique. " + 'There is already a database with the client secret key "4".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "5".' + ) + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_client_access_key_db, client_access_key_conflict_error), + (bad_client_secret_key_db, client_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + response = requests.post( + url=databases_url, + json=bad_database.to_dict(), + timeout=30, + ) + + assert response.status_code == HTTPStatus.CONFLICT + assert response.text == expected_message + + @staticmethod + def test_give_no_details(high_quality_image: io.BytesIO) -> None: + """It is possible to create a cloud database without giving any + data. + """ + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post(url=databases_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.CREATED + + data = json.loads(s=response.text) + + assert data["targets"] == [] + assert data["state_name"] == "WORKING" + assert "database_name" in data + + vws_client = VWS( + server_access_key=data["server_access_key"], + server_secret_key=data["server_secret_key"], + ) + + cloud_reco_client = CloudRecoService( + client_access_key=data["client_access_key"], + client_secret_key=data["client_secret_key"], + ) + + assert not vws_client.list_targets() + assert not cloud_reco_client.query(image=high_quality_image) + + +class TestAddVuMarkDatabase: + """Tests for adding VuMark databases to the mock.""" + + @staticmethod + def test_duplicate_keys() -> None: + """ + It is not possible to have multiple VuMark databases with + matching + keys. + """ + database = VuMarkDatabase( + server_access_key="1", + server_secret_key="2", + database_name="3", + ) + + bad_server_access_key_db = VuMarkDatabase(server_access_key="1") + bad_server_secret_key_db = VuMarkDatabase(server_secret_key="2") + bad_database_name_db = VuMarkDatabase(database_name="3") + + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' + ) + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "3".' + ) + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + response = requests.post( + url=databases_url, + json=bad_database.to_dict(), + timeout=30, + ) + + assert response.status_code == HTTPStatus.CONFLICT + assert response.text == expected_message + + +class TestDeleteCloudDatabase: + """Tests for deleting cloud databases from the mock.""" + + @staticmethod + def test_not_found() -> None: + """ + A 404 error is returned when trying to delete a cloud database + which does not exist. + """ + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + delete_url = databases_url + "/" + "foobar" + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + @staticmethod + def test_delete_cloud_database() -> None: + """It is possible to delete a cloud database.""" + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post(url=databases_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.CREATED + + data = json.loads(s=response.text) + delete_url = databases_url + "/" + data["database_name"] + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.OK + + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestDeleteVuMarkDatabase: + """Tests for deleting VuMark databases from the mock.""" + + @staticmethod + def test_not_found() -> None: + """ + A 404 error is returned when trying to delete a VuMark database + which does not exist. + """ + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + delete_url = databases_url + "/" + "foobar" + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + @staticmethod + def test_delete_vumark_database() -> None: + """It is possible to delete a VuMark database.""" + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + response = requests.post(url=databases_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.CREATED + + data = json.loads(s=response.text) + delete_url = databases_url + "/" + data["database_name"] + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.OK + + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestQueryImageMatchers: + """Tests for query image matchers.""" + + @staticmethod + def test_exact_match( + *, + high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The exact matcher matches only exactly the same images.""" + monkeypatch.setenv(name="QUERY_IMAGE_MATCHER", value="exact") + + database = CloudDatabase() + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert len(same_image_result) == 1 + different_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert not different_image_result + + @staticmethod + def test_structural_similarity_matcher( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The structural similarity matcher matches similar images.""" + monkeypatch.setenv( + name="QUERY_IMAGE_MATCHER", + value="structural_similarity", + ) + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + assert re_exported_image.getvalue() != high_quality_image.getvalue() + + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert len(same_image_result) == 1 + similar_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert len(similar_image_result) == 1 + + different_image_result = cloud_reco_client.query( + image=different_high_quality_image, + ) + assert not different_image_result + + +class TestDuplicatesImageMatchers: + """Tests for duplicates image matchers.""" + + @staticmethod + def test_exact_match( + *, + high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The exact matcher matches only exactly the same images.""" + monkeypatch.setenv(name="DUPLICATES_IMAGE_MATCHER", value="exact") + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + target_id = vws_client.add_target( + name="example_0", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + not_duplicate_target_id = vws_client.add_target( + name="example_2", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + vws_client.wait_for_target_processed( + target_id=not_duplicate_target_id, + ) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + @staticmethod + def test_structural_similarity_matcher( + *, + high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The structural similarity matcher matches similar images.""" + monkeypatch.setenv( + name="DUPLICATES_IMAGE_MATCHER", + value="structural_similarity", + ) + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + +class TestTargetRaters: + """Tests for using target raters.""" + + @staticmethod + def test_default( + *, + image_file_success_state_low_rating: io.BytesIO, + high_quality_image: io.BytesIO, + ) -> None: + """By default, the BRISQUE target rater is used.""" + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + low_rating_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + application_metadata=None, + active_flag=True, + ) + + high_quality_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + + for target_id in ( + low_rating_image_target_id, + high_quality_image_target_id, + ): + vws_client.wait_for_target_processed(target_id=target_id) + + low_rated_image_rating = vws_client.get_target_record( + target_id=low_rating_image_target_id, + ).target_record.tracking_rating + + high_quality_image_rating = vws_client.get_target_record( + target_id=high_quality_image_target_id, + ).target_record.tracking_rating + + assert low_rated_image_rating <= 0 + assert high_quality_image_rating > 1 + + @staticmethod + def test_brisque( + *, + monkeypatch: pytest.MonkeyPatch, + image_file_success_state_low_rating: io.BytesIO, + high_quality_image: io.BytesIO, + ) -> None: + """It is possible to use the BRISQUE target rater.""" + monkeypatch.setenv(name="TARGET_RATER", value="brisque") + + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + low_rating_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + application_metadata=None, + active_flag=True, + ) + + high_quality_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + + for target_id in ( + low_rating_image_target_id, + high_quality_image_target_id, + ): + vws_client.wait_for_target_processed(target_id=target_id) + + low_rated_image_rating = vws_client.get_target_record( + target_id=low_rating_image_target_id, + ).target_record.tracking_rating + + high_quality_image_rating = vws_client.get_target_record( + target_id=high_quality_image_target_id, + ).target_record.tracking_rating + + assert low_rated_image_rating <= 0 + assert high_quality_image_rating > 1 + + @staticmethod + def test_perfect( + *, + monkeypatch: pytest.MonkeyPatch, + high_quality_image: io.BytesIO, + ) -> None: + """It is possible to use the perfect target rater.""" + monkeypatch.setenv(name="TARGET_RATER", value="perfect") + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + for _ in range(50) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + ratings_set = { + vws_client.get_target_record( + target_id=target_id + ).target_record.tracking_rating + for target_id in target_ids + } + + assert ratings_set == {5} + + @staticmethod + def test_random( + *, + monkeypatch: pytest.MonkeyPatch, + high_quality_image: io.BytesIO, + ) -> None: + """It is possible to use the random target rater.""" + monkeypatch.setenv(name="TARGET_RATER", value="random") + + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + for _ in range(50) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + ratings = [ + vws_client.get_target_record( + target_id=target_id + ).target_record.tracking_rating + for target_id in target_ids + ] + + sorted_ratings = sorted(ratings) + lowest_rating = sorted_ratings[0] + highest_rating = sorted_ratings[-1] + minimum_rating = 0 + maximum_rating = 5 + assert lowest_rating >= minimum_rating + assert highest_rating <= maximum_rating + assert lowest_rating != highest_rating + + +class TestVuMarkTargetStatus: + """Tests for VuMark instance generation when target status is + validated (Flask app code path). + """ + + @staticmethod + def test_processing_target_returns_forbidden() -> None: + """A VuMark target still processing returns 403 when generating + an instance via the Flask app. + """ + vumark_target = VuMarkTarget( + name="processing-target", + processing_time_seconds=9999, + ) + vumark_database = VuMarkDatabase( + vumark_targets=set(), + ) + + vumark_databases_url = ( + _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + ) + response = requests.post( + url=vumark_databases_url, + json=vumark_database.to_dict(), + timeout=30, + ) + assert response.status_code == HTTPStatus.CREATED + database_data = json.loads(s=response.text) + + vumark_targets_url = ( + f"{vumark_databases_url}" + f"/{database_data['database_name']}/vumark_targets" + ) + response = requests.post( + url=vumark_targets_url, + json=vumark_target.to_dict(), + timeout=30, + ) + assert response.status_code == HTTPStatus.CREATED + + request_path = f"/targets/{vumark_target.target_id}/instances" + content_type = "application/json" + content = json.dumps( + obj={"instance_id": uuid.uuid4().hex}, + ).encode(encoding="utf-8") + date = rfc_1123_date() + authorization_string = authorization_header( + access_key=vumark_database.server_access_key, + secret_key=vumark_database.server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + response = requests.post( + url="https://vws.vuforia.com" + request_path, + headers={ + "Accept": "image/png", + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + }, + data=content, + timeout=30, + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.TARGET_STATUS_NOT_SUCCESS.value + ) + + +class TestModelTargetWebAPI: + """Tests for the Model Target Web API through the Flask app.""" + + @staticmethod + def test_standard_dataset_workflow( + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A Model Target dataset can be created and downloaded.""" + monkeypatch.setenv(name="PROCESSING_TIME_SECONDS", value="0") + token_response = requests.post( + url="https://vws.vuforia.com/oauth2/token", + auth=("client-id", "client-secret"), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + token = token_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + create_response = requests.post( + url="https://vws.vuforia.com/modeltargets/datasets", + headers=headers, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + status_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + dataset_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/dataset" + ), + headers=headers, + timeout=30, + ) + + assert token_response.status_code == HTTPStatus.OK + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.json()["status"] == "done" + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=dataset_response.content), + ) as dataset_zip: + assert dataset_zip.namelist() == ["dataset.json"] + + @staticmethod + def _dataset_status(dataset_uuid: str) -> dict[str, Any]: + """Return a dataset's status response body from the VWS app.""" + token_response = requests.post( + url="https://vws.vuforia.com/oauth2/token", + auth=("client-id", "client-secret"), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + token = token_response.json()["access_token"] + status_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/status" + ), + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + assert status_response.status_code == HTTPStatus.OK + status_body: dict[str, Any] = status_response.json() + return status_body + + def test_seeded_generation_failure(self) -> None: + """A dataset seeded with a generation failure through the target + manager API reports the failure through the VWS app. + """ + dataset = ModelTargetDataset( + request_body=_MODEL_TARGET_DATASET_REQUEST, + dataset_type=ModelTargetDatasetType.STANDARD, + processing_time_seconds=0.0, + generation_failure=ModelTargetGenerationFailure( + message="Seeded failure", + ), + generation_warning=None, + ) + datasets_url = ( + _EXAMPLE_URL_FOR_TARGET_MANAGER + "/model_target_datasets" + ) + create_response = requests.post( + url=datasets_url, + json=dataset.to_dict(), + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + status_body = self._dataset_status(dataset_uuid=dataset.uuid_) + assert status_body["status"] == "failed" + assert status_body["error"]["message"] == "Seeded failure" + + def test_seeded_generation_warning(self) -> None: + """A dataset seeded with a generation warning through the target + manager API reports the warning through the VWS app. + """ + dataset = ModelTargetDataset( + request_body=_MODEL_TARGET_DATASET_REQUEST, + dataset_type=ModelTargetDatasetType.STANDARD, + processing_time_seconds=0.0, + generation_failure=None, + generation_warning=ModelTargetGenerationWarning( + message="Seeded warning", + ), + ) + datasets_url = ( + _EXAMPLE_URL_FOR_TARGET_MANAGER + "/model_target_datasets" + ) + create_response = requests.post( + url=datasets_url, + json=dataset.to_dict(), + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + status_body = self._dataset_status(dataset_uuid=dataset.uuid_) + assert status_body["status"] == "done" + assert status_body["warning"]["message"] == "Seeded warning" + + @staticmethod + def test_delete_unknown_dataset() -> None: + """Deleting an unknown dataset from the target manager returns a + 404 response. + """ + datasets_url = ( + _EXAMPLE_URL_FOR_TARGET_MANAGER + "/model_target_datasets" + ) + delete_response = requests.delete( + url=datasets_url + "/" + uuid.uuid4().hex, + timeout=30, + ) + + assert delete_response.status_code == HTTPStatus.NOT_FOUND + + +class TestResponseDelay: + """Tests for the response delay feature. + + These tests run through the ``responses`` library, which intercepts + requests in-process. Because of this, the client ``timeout`` parameter + is not enforced — the delay blocks but never raises + ``requests.exceptions.Timeout``. When running the Flask app as a real + server (e.g. in Docker), the delay causes a genuinely slow HTTP + response and the ``requests`` client will raise ``Timeout`` on its own. + """ + + DELAY_SECONDS = 0.5 + + @staticmethod + def _make_request() -> None: + """Make a request to the VWS API.""" + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=30, + ) + + def test_default_no_delay(self) -> None: + """By default, there is no response delay.""" + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + start = time.monotonic() + self._make_request() + elapsed = time.monotonic() - start + assert elapsed < self.DELAY_SECONDS + + def test_delay_is_applied( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """When response_delay_seconds is set, the response is delayed.""" + monkeypatch.setenv( + name="RESPONSE_DELAY_SECONDS", + value=f"{self.DELAY_SECONDS}", + ) + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + start = time.monotonic() + self._make_request() + elapsed = time.monotonic() - start + assert elapsed >= self.DELAY_SECONDS diff --git a/tests/mock_vws/test_get_duplicates.py b/tests/mock_vws/test_get_duplicates.py index 481bce14e..a245bf1b4 100644 --- a/tests/mock_vws/test_get_duplicates.py +++ b/tests/mock_vws/test_get_duplicates.py @@ -1,33 +1,30 @@ -""" -Tests for the mock of the get duplicates endpoint. -""" +"""Tests for the mock of the get duplicates endpoint.""" +import copy import io import uuid import pytest +from PIL import Image from vws import VWS -from vws.exceptions import ProjectInactive +from vws.exceptions.vws_exceptions import ProjectInactiveError from vws.reports import TargetStatuses +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend -@pytest.mark.usefixtures('verify_mock_vuforia') + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDuplicates: - """ - Tests for the mock of the target duplicates endpoint. - """ + """Tests for the mock of the target duplicates endpoint.""" + @staticmethod def test_duplicates( - self, + *, high_quality_image: io.BytesIO, image_file_success_state_low_rating: io.BytesIO, vws_client: VWS, ) -> None: - """ - Target IDs of similar targets are returned. - - In the mock, "similar" means that the images are exactly the same. - """ + """Target IDs of the exact same targets are returned.""" image_data = high_quality_image different_image_data = image_file_success_state_low_rating @@ -65,14 +62,53 @@ def test_duplicates( assert duplicates == [similar_target_id] + @staticmethod + def test_duplicates_not_same( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + ) -> None: + """Target IDs of similar targets are returned.""" + image_data = high_quality_image + similar_image_data = copy.copy(x=image_data) + similar_image_buffer = io.BytesIO() + pil_similar_image = Image.open(fp=similar_image_data) + # Re-save means similar but not identical. + pil_similar_image.save(fp=similar_image_buffer, format="JPEG") + assert similar_image_buffer.getvalue() != image_data.getvalue() + + original_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_data, + active_flag=True, + application_metadata=None, + ) + + similar_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=similar_image_buffer, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=original_target_id) + vws_client.wait_for_target_processed(target_id=similar_target_id) + + duplicates = vws_client.get_duplicate_targets( + target_id=original_target_id, + ) + + assert duplicates == [similar_target_id] + + @staticmethod def test_status( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - Targets are not duplicates if the status is not 'success'. - """ + """Targets are not duplicates if the status is not 'success'.""" original_target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -103,23 +139,57 @@ def test_status( assert duplicates == [] + @staticmethod + def test_order_is_upload_date_then_target_id( + *, + verify_mock_vuforia: VuforiaBackend, + high_quality_image: io.BytesIO, + vws_client: VWS, + ) -> None: + """The mock returns duplicates ordered by upload date. -@pytest.mark.usefixtures('verify_mock_vuforia') + The real Vuforia Web Services do not document an order, so we do + not verify this against them. + """ + if verify_mock_vuforia == VuforiaBackend.REAL: + pytest.skip(reason="The real Vuforia does not document an order.") + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + duplicates = vws_client.get_duplicate_targets( + target_id=target_ids[0], + ) + + assert duplicates == target_ids[1:] + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for the effects of the active flag on duplicate matching. - """ + """Tests for the effects of the active flag on duplicate matching.""" + @staticmethod def test_active_flag( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: - """ - Targets with `active_flag` set to `False` can have duplicates. - Targets with `active_flag` set to `False` are not found as duplicates. + """Targets with `active_flag` set to `False` can have duplicates. + Targets with `active_flag` set to `False` are not found as + duplicates. - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Check-for-Duplicate-Targets + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check says: ''' @@ -159,19 +229,18 @@ def test_active_flag( assert duplicates == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestProcessing: - """ - Tests for targets in the processing stage. - """ + """Tests for targets in the processing stage.""" + @staticmethod def test_processing( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: - """ - If a target is in the processing state, it can have duplicates. + """If a target is in the processing state, it can have duplicates. + Targets can have duplicates in the processing state. """ processed_target_id = vws_client.add_target( @@ -205,24 +274,24 @@ def test_processing( target_id=processing_target_id, ) + # There is a race condition here. + # If getting the target details and getting the duplicates takes longer + # than the processing time, the target will be in the success state. assert target_details.status == TargetStatuses.PROCESSING assert duplicates == [processed_target_id] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - with pytest.raises(ProjectInactive): + with pytest.raises(expected_exception=ProjectInactiveError): inactive_vws_client.get_duplicate_targets( target_id=uuid.uuid4().hex, ) diff --git a/tests/mock_vws/test_get_target.py b/tests/mock_vws/test_get_target.py index 799bab762..325517864 100644 --- a/tests/mock_vws/test_get_target.py +++ b/tests/mock_vws/test_get_target.py @@ -1,7 +1,6 @@ -""" -Tests for getting a target record. +"""Tests for getting a target record. -https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Retrieve-a-Target-Record +https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record """ import io @@ -9,25 +8,22 @@ import pytest from vws import VWS -from vws.exceptions import UnknownTarget +from vws.exceptions.vws_exceptions import UnknownTargetError from vws.reports import TargetRecord, TargetStatuses -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestGetRecord: - """ - Tests for getting a target record. - """ + """Tests for getting a target record.""" + @staticmethod def test_get_vws_target( - self, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - Details of a target are returned. - """ - name = 'my_example_name' + """Details of a target are returned.""" + name = "my_example_name" width = 1234 target_id = vws_client.add_target( @@ -51,22 +47,24 @@ def test_get_vws_target( name=name, width=width, tracking_rating=tracking_rating, - reco_rating='', + reco_rating="", ) assert target_record == expected_target_record + @staticmethod def test_fail_status( - self, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - When a 1x1 image is given, the status changes from 'processing' to + When a 1x1 image is given, the status changes from 'processing' + to 'failed' after some time. """ target_id = vws_client.add_target( - name='my_example_name', + name="my_example_name", width=1, image=image_file_failed_state, active_flag=True, @@ -79,21 +77,23 @@ def test_fail_status( # Tracking rating is 0 when status is 'failed' assert target_details.target_record.tracking_rating == 0 + @staticmethod def test_success_status( - self, + *, image_file_success_state_low_rating: io.BytesIO, vws_client: VWS, ) -> None: - """ - When a random, large enough image is given, the status changes from + """When a random, large enough image is given, the status changes + from 'processing' to 'success' after some time. - The mock is much more lenient than the real implementation of VWS. - The test image does not prove that what is counted as a success in the - mock will be counted as a success in the real implementation. + The mock is much more lenient than the real implementation of + VWS. The test image does not prove that what is counted as a + success in the mock will be counted as a success in the real + implementation. """ target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file_success_state_low_rating, active_flag=True, @@ -114,18 +114,60 @@ def test_success_status( assert new_tracking_rating == tracking_rating -@pytest.mark.usefixtures('verify_mock_vuforia') -class TestInactiveProject: +def _get_target_tracking_rating( + *, + vws_client: VWS, + image_file: io.BytesIO, +) -> int: + """Get the tracking rating of a target with the given image.""" + target_id = vws_client.add_target( + name=f"example_{uuid.uuid4().hex}", + width=1, + image=image_file, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + target_details = vws_client.get_target_record(target_id=target_id) + return target_details.target_record.tracking_rating + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestTargetTrackingRating: """ - Tests for inactive projects. + Tests which exercise the target tracking_rating, and check the image + fixtures we use. """ - def test_inactive_project( - self, - inactive_vws_client: VWS, + @staticmethod + def test_target_quality( + *, + vws_client: VWS, + high_quality_image: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, ) -> None: - """ - The project's active state does not affect getting a target. - """ - with pytest.raises(UnknownTarget): + """The target tracking rating is as expected.""" + high_quality_image_tracking_rating = _get_target_tracking_rating( + vws_client=vws_client, + image_file=high_quality_image, + ) + low_quality_image_tracking_rating = _get_target_tracking_rating( + vws_client=vws_client, + image_file=image_file_success_state_low_rating, + ) + assert ( + high_quality_image_tracking_rating + > low_quality_image_tracking_rating + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestInactiveProject: + """Tests for inactive projects.""" + + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect getting a target.""" + with pytest.raises(expected_exception=UnknownTargetError): inactive_vws_client.get_target_record(target_id=uuid.uuid4().hex) diff --git a/tests/mock_vws/test_healthcheck.py b/tests/mock_vws/test_healthcheck.py new file mode 100644 index 000000000..8cbd5cd91 --- /dev/null +++ b/tests/mock_vws/test_healthcheck.py @@ -0,0 +1,84 @@ +"""Tests for the health check used by the Docker images.""" + +import socket +import threading +from collections.abc import Generator +from contextlib import contextmanager +from http import HTTPStatus + +import pytest +from beartype import beartype +from flask import Flask, Response +from werkzeug.serving import make_server + +from mock_vws._flask_server.healthcheck import flask_app_healthy + + +@beartype +@contextmanager +def _app_responding_with(*, status: HTTPStatus) -> Generator[int]: + """Serve an app which gives the given status, and yield its port.""" + app = Flask(import_name=__name__, static_folder=None) + + @beartype + def _respond(_path: str) -> Response: + """Respond with the given status and an empty body.""" + return Response(status=status) + + app.add_url_rule(rule="/", view_func=_respond) + + server = make_server(host="localhost", port=0, app=app) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_port + finally: + server.shutdown() + thread.join() + + +@beartype +def _unused_port() -> int: + """Return a port with nothing listening on it.""" + with socket.socket() as sock: + sock.bind(("localhost", 0)) + port: int = sock.getsockname()[1] + return port + + +@beartype +def test_nothing_listening() -> None: + """No server on the port means not healthy. + + This is the state during container start-up, before the app binds the + port. + """ + assert not flask_app_healthy(port=_unused_port()) + + +@pytest.mark.parametrize( + argnames="status", + argvalues=[ + HTTPStatus.NOT_FOUND, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ], +) +@beartype +def test_healthy(*, status: HTTPStatus) -> None: + """An app which handles a request for an unknown endpoint is + healthy. + """ + with _app_responding_with(status=status) as port: + assert flask_app_healthy(port=port) + + +@pytest.mark.parametrize( + argnames="status", + argvalues=[HTTPStatus.OK, HTTPStatus.INTERNAL_SERVER_ERROR], +) +@beartype +def test_unhealthy(*, status: HTTPStatus) -> None: + """Any other status means not healthy.""" + with _app_responding_with(status=status) as port: + assert not flask_app_healthy(port=port) diff --git a/tests/mock_vws/test_invalid_given_id.py b/tests/mock_vws/test_invalid_given_id.py index f50019ec6..29eaf70ee 100644 --- a/tests/mock_vws/test_invalid_given_id.py +++ b/tests/mock_vws/test_invalid_given_id.py @@ -1,49 +1,196 @@ -""" -Tests for passing invalid target IDs to endpoints which -require a target ID to be given. +"""Tests for requests which name something that VWS does not serve. + +These cover an invalid target ID given to an endpoint which requires one, a +path which VWS does not serve, and a served path with a method which that +path does not serve. + +The tests for paths and methods live here, rather than in a file of their +own, because every entry in the CI test matrix uses one of the credentials +files in ``secrets.tar.gpg``, and there are exactly as many of those files as +there are entries. """ -from http import HTTPStatus +from dataclasses import dataclass +from http import HTTPMethod, HTTPStatus import pytest import requests +from beartype import beartype from vws import VWS +from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes +from mock_vws._flask_server.vws import VWS_FLASK_APP +from mock_vws.database import CloudDatabase +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import assert_vws_failure +from tests.mock_vws.utils.too_many_requests import handle_server_errors + +_VWS_HOST = "https://vws.vuforia.com" + + +@beartype +@dataclass(frozen=True, kw_only=True) +class _UnroutedResponse: + """The parts of a response to a request which no route serves.""" + + status_code: int + body: bytes + content_type: str | None + + +@beartype +def _send_unrouted_request( + *, + backend: VuforiaBackend, + vuforia_database: CloudDatabase, + method: HTTPMethod, + request_path: str, +) -> _UnroutedResponse | None: + """Send a signed request which no route serves and return the response. + ``None`` is returned when the backend refuses the connection rather than + returning a response. + """ + date = rfc_1123_date() + headers = { + "Authorization": authorization_header( + access_key=vuforia_database.server_access_key, + secret_key=vuforia_database.server_secret_key, + method=method, + content=b"", + content_type="", + date=date, + request_path=request_path, + ), + "Date": date, + } -@pytest.mark.usefixtures('verify_mock_vuforia') + if backend == VuforiaBackend.DOCKER_IN_MEMORY: + # The ``responses`` library intercepts only the paths and methods + # which the Flask app routes, so requests to any other path never + # reach the app. A running container serves every path, so we drive + # the app with its own test client. + test_client_response = VWS_FLASK_APP.test_client().open( + request_path, + method=method, + headers=headers, + ) + return _UnroutedResponse( + status_code=test_client_response.status_code, + body=test_client_response.data, + content_type=test_client_response.headers.get( + key="Content-Type", + ), + ) + + try: + response = requests.request( + method=method, + url=_VWS_HOST + request_path, + headers=headers, + timeout=30, + ) + except requests.exceptions.ConnectionError: + return None + + return _UnroutedResponse( + status_code=response.status_code, + body=response.content, + content_type=response.headers.get("Content-Type"), + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInvalidGivenID: """ - Tests for giving an invalid ID to endpoints which require a target ID to - be given. + Tests for giving an invalid ID to endpoints which require a target + ID to be + given. """ + @staticmethod def test_not_real_id( - self, + *, vws_client: VWS, endpoint: Endpoint, target_id: str, ) -> None: """ - A `NOT_FOUND` error is returned when an endpoint is given a target ID + A `NOT_FOUND` error is returned when an endpoint is given a + target ID of a target which does not exist. """ - if not endpoint.prepared_request.path_url.endswith(target_id): + # This shared check only covers endpoints that end in target_id, + # such as /targets/{target_id}. Endpoints with trailing segments + # are covered by endpoint-specific tests. + if not endpoint.path_url.endswith(target_id): return - vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, - ) + response = endpoint.send() + + handle_server_errors(response=response) assert_vws_failure( response=response, status_code=HTTPStatus.NOT_FOUND, result_code=ResultCodes.UNKNOWN_TARGET, ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestUnroutedRequests: + """Tests for requests which VWS does not serve.""" + + @staticmethod + def test_unknown_path( + *, + vuforia_database: CloudDatabase, + verify_mock_vuforia: VuforiaBackend, + ) -> None: + """A request to a path which is not served returns a 404 with no + body. + """ + response = _send_unrouted_request( + backend=verify_mock_vuforia, + vuforia_database=vuforia_database, + method=HTTPMethod.GET, + request_path="/some-random-endpoint", + ) + + if verify_mock_vuforia == VuforiaBackend.MOCK: + # The ``requests`` and ``httpx`` backends mock only the paths + # which they serve, so they give no response at all. + assert response is None + return + + assert response is not None + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.body == b"" + assert response.content_type is None + + @staticmethod + def test_unknown_method( + *, + vuforia_database: CloudDatabase, + verify_mock_vuforia: VuforiaBackend, + ) -> None: + """A request to a served path with a method which that path does + not serve returns a 404, rather than a 405. + """ + response = _send_unrouted_request( + backend=verify_mock_vuforia, + vuforia_database=vuforia_database, + method=HTTPMethod.DELETE, + request_path="/summary", + ) + + if verify_mock_vuforia == VuforiaBackend.MOCK: + assert response is None + return + + assert response is not None + assert response.status_code == HTTPStatus.NOT_FOUND diff --git a/tests/mock_vws/test_invalid_json.py b/tests/mock_vws/test_invalid_json.py index 3168dd855..35b3253cd 100644 --- a/tests/mock_vws/test_invalid_json.py +++ b/tests/mock_vws/test_invalid_json.py @@ -1,111 +1,191 @@ -""" -Tests for giving invalid JSON to endpoints. -""" +"""Tests for giving invalid JSON to endpoints.""" +import json from datetime import datetime, timedelta from http import HTTPStatus from urllib.parse import urlparse +from zoneinfo import ZoneInfo import pytest -import requests -from backports.zoneinfo import ZoneInfo from freezegun import freeze_time -from requests.structures import CaseInsensitiveDict from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( assert_valid_date_header, + assert_valid_transaction_id, assert_vwq_failure, assert_vws_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInvalidJSON: - """ - Tests for giving invalid JSON to endpoints. - """ - - @pytest.mark.parametrize('date_skew_minutes', [0, 10]) - def test_invalid_json( - self, - endpoint: Endpoint, - date_skew_minutes: int, - ) -> None: - """ - Giving invalid JSON to endpoints returns error responses. - """ - date_is_skewed = not date_skew_minutes == 0 - content = b'a' - gmt = ZoneInfo('GMT') + """Tests for giving invalid JSON to endpoints.""" + + @staticmethod + def test_invalid_json(endpoint: Endpoint) -> None: + """Giving invalid JSON to endpoints returns error responses.""" + content = b"a" + gmt = ZoneInfo(key="GMT") now = datetime.now(tz=gmt) - time_to_freeze = now + timedelta(minutes=date_skew_minutes) - with freeze_time(time_to_freeze): + time_to_freeze = now + with freeze_time(time_to_freeze=time_to_freeze): date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), + method=endpoint.method, content=content, content_type=endpoint.auth_header_content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), } - endpoint.prepared_request.body = content - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - endpoint.prepared_request.prepare_content_length(body=content) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) + response = new_endpoint.send() + + handle_server_errors(response=response) + takes_json_data = ( - endpoint.auth_header_content_type == 'application/json' + endpoint.auth_header_content_type == "application/json" ) assert_valid_date_header(response=response) - if date_is_skewed and takes_json_data: - # On the real implementation, we get `HTTPStatus.FORBIDDEN` and - # `REQUEST_TIME_TOO_SKEWED`. - # See https://github.com/VWS-Python/vws-python-mock/issues/4 for - # implementing this on them mock. - return - - if not date_is_skewed and takes_json_data: + if takes_json_data: + expected_result_code = ( + ResultCodes.BAD_REQUEST + if endpoint.path_url.endswith("/instances") + else ResultCodes.FAIL + ) assert_vws_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - result_code=ResultCodes.FAIL, + result_code=expected_result_code, ) return - assert response.status_code == HTTPStatus.BAD_REQUEST - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - content_type='text/html;charset=UTF-8', - ) - expected_text = ( - 'java.lang.RuntimeException: RESTEASY007500: ' - 'Could find no Content-Disposition header within part' + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) + expected_text = "No image." assert response.text == expected_text return - assert response.text == '' - assert 'Content-Type' not in response.headers + assert response.status_code == HTTPStatus.BAD_REQUEST + assert not response.text + assert "Content-Type" not in response.headers + + @staticmethod + def test_invalid_json_with_skewed_time(endpoint: Endpoint) -> None: + """Giving invalid JSON to endpoints returns error responses.""" + # We use a skew of 70 because the maximum allowed skew for services is + # 5 minutes, and for query is 65 minutes. 70 is comfortably larger than + # the max of these two. + date_skew_minutes = 70 + content = b"a" + gmt = ZoneInfo(key="GMT") + now = datetime.now(tz=gmt) + time_to_freeze = now + timedelta(minutes=date_skew_minutes) + with freeze_time(time_to_freeze=time_to_freeze): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=content, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + takes_json_data = ( + endpoint.auth_header_content_type == "application/json" + ) + + assert_valid_date_header(response=response) + + if takes_json_data: + assert_vws_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + result_code=ResultCodes.REQUEST_TIME_TOO_SKEWED, + ) + return + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == { + "transaction_id", + "result_code", + } + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert not response.text + assert "Content-Type" not in response.headers diff --git a/tests/mock_vws/test_model_target_generation_failure.py b/tests/mock_vws/test_model_target_generation_failure.py new file mode 100644 index 000000000..266850dfb --- /dev/null +++ b/tests/mock_vws/test_model_target_generation_failure.py @@ -0,0 +1,126 @@ +"""Tests for configurable Model Target dataset generation failures.""" + +from collections.abc import Callable +from http import HTTPStatus +from typing import Any + +import httpx +import pytest +import requests + +from mock_vws import MockVWS, ModelTargetGenerationFailure + +_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_CREATE_URL = "https://vws.vuforia.com/modeltargets/datasets" +_REQUEST_BODY: dict[str, Any] = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} +type _HTTPResponse = requests.Response | httpx.Response +type _RequestSender = Callable[[str, dict[str, Any] | None], _HTTPResponse] + + +def _requests_request( + url: str, + json_body: dict[str, Any] | None, +) -> _HTTPResponse: + """Send a Model Target request with ``requests``.""" + if json_body is None: + return requests.get( + url=url, + headers={"Authorization": _AUTHORIZATION}, + timeout=30, + ) + return requests.post( + url=url, + headers={"Authorization": _AUTHORIZATION}, + json=json_body, + timeout=30, + ) + + +def _httpx_request( + url: str, + json_body: dict[str, Any] | None, +) -> _HTTPResponse: + """Send a Model Target request with ``httpx``.""" + if json_body is None: + return httpx.get( + url=url, + headers={"Authorization": _AUTHORIZATION}, + timeout=30, + ) + return httpx.post( + url=url, + headers={"Authorization": _AUTHORIZATION}, + json=json_body, + timeout=30, + ) + + +@pytest.mark.parametrize( + argnames="send_request", + argvalues=[_requests_request, _httpx_request], + ids=["requests", "httpx"], +) +@pytest.mark.parametrize( + argnames=("processing_time_seconds", "expected_status", "time_field"), + argvalues=[ + pytest.param(60.0, "processing", "eta", id="processing"), + pytest.param(0.0, "failed", "completedAt", id="failed"), + ], +) +def test_configured_generation_failure( + *, + send_request: _RequestSender, + processing_time_seconds: float, + expected_status: str, + time_field: str, +) -> None: + """A configured failure is returned only after processing + completes. + """ + failure = ModelTargetGenerationFailure(message="CAD model is invalid") + with MockVWS( + processing_time_seconds=processing_time_seconds, + model_target_generation_failure=failure, + ): + create_response = send_request(_CREATE_URL, _REQUEST_BODY) + dataset_uuid = create_response.json()["uuid"] + status_response = send_request( + f"{_CREATE_URL}/{dataset_uuid}/status", + None, + ) + + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.status_code == HTTPStatus.OK + status_body = status_response.json() + assert status_body["status"] == expected_status + assert status_body["uuid"] == dataset_uuid + assert isinstance(status_body["createdAt"], str) + assert isinstance(status_body[time_field], str) + assert {"eta", "completedAt"} & status_body.keys() == {time_field} + expected_error = ( + { + "code": "ERROR", + "message": "CAD model is invalid", + } + if expected_status == "failed" + else None + ) + assert status_body.get("error") == expected_error diff --git a/tests/mock_vws/test_model_target_generation_warning.py b/tests/mock_vws/test_model_target_generation_warning.py new file mode 100644 index 000000000..51b8d216c --- /dev/null +++ b/tests/mock_vws/test_model_target_generation_warning.py @@ -0,0 +1,157 @@ +"""Tests for configurable Model Target dataset generation warnings.""" + +from collections.abc import Callable +from http import HTTPStatus +from typing import Any + +import httpx +import pytest +import requests + +from mock_vws import ( + MockVWS, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) + +_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_CREATE_URL = "https://vws.vuforia.com/modeltargets/datasets" +_REQUEST_BODY: dict[str, Any] = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} +type _HTTPResponse = requests.Response | httpx.Response +type _RequestSender = Callable[[str, dict[str, Any] | None], _HTTPResponse] + + +def _requests_request( + url: str, + json_body: dict[str, Any] | None, +) -> _HTTPResponse: + """Send a Model Target request with ``requests``.""" + if json_body is None: + return requests.get( + url=url, + headers={"Authorization": _AUTHORIZATION}, + timeout=30, + ) + return requests.post( + url=url, + headers={"Authorization": _AUTHORIZATION}, + json=json_body, + timeout=30, + ) + + +def _httpx_request( + url: str, + json_body: dict[str, Any] | None, +) -> _HTTPResponse: + """Send a Model Target request with ``httpx``.""" + if json_body is None: + return httpx.get( + url=url, + headers={"Authorization": _AUTHORIZATION}, + timeout=30, + ) + return httpx.post( + url=url, + headers={"Authorization": _AUTHORIZATION}, + json=json_body, + timeout=30, + ) + + +@pytest.mark.parametrize( + argnames="send_request", + argvalues=[_requests_request, _httpx_request], + ids=["requests", "httpx"], +) +@pytest.mark.parametrize( + argnames=("processing_time_seconds", "expected_status", "time_field"), + argvalues=[ + pytest.param(60.0, "processing", "eta", id="processing"), + pytest.param(0.0, "done", "completedAt", id="done"), + ], +) +def test_configured_generation_warning( + *, + send_request: _RequestSender, + processing_time_seconds: float, + expected_status: str, + time_field: str, +) -> None: + """A configured warning is returned only after processing + completes. + """ + details = [ + { + "code": "LOW_RECOGNITION_QUALITY", + "message": "The model has substandard recognition quality.", + "innerError": { + "code": "SYMMETRIES_OR_AMBIGUITIES", + "targets": [{"model": "model-name"}], + }, + }, + ] + warning = ModelTargetGenerationWarning( + message="Warning after creating dataset", + details=details, + ) + with MockVWS( + processing_time_seconds=processing_time_seconds, + model_target_generation_warning=warning, + ): + create_response = send_request(_CREATE_URL, _REQUEST_BODY) + dataset_uuid = create_response.json()["uuid"] + status_response = send_request( + f"{_CREATE_URL}/{dataset_uuid}/status", + None, + ) + + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.status_code == HTTPStatus.OK + status_body = status_response.json() + assert status_body["status"] == expected_status + assert status_body["uuid"] == dataset_uuid + assert isinstance(status_body["createdAt"], str) + assert isinstance(status_body[time_field], str) + assert {"eta", "completedAt"} & status_body.keys() == {time_field} + expected_warning = ( + { + "code": "WARNING", + "message": "Warning after creating dataset", + "target": dataset_uuid, + "details": details, + } + if expected_status == "done" + else None + ) + assert status_body.get("warning") == expected_warning + + +def test_generation_warning_and_failure_are_mutually_exclusive() -> None: + """A dataset cannot be configured to both fail and succeed.""" + with pytest.raises( + expected_exception=ValueError, + match="failure and warning configurations are mutually exclusive", + ): + MockVWS( + model_target_generation_failure=ModelTargetGenerationFailure(), + model_target_generation_warning=ModelTargetGenerationWarning(), + ) diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py new file mode 100644 index 000000000..c8357ebc0 --- /dev/null +++ b/tests/mock_vws/test_model_target_web_api.py @@ -0,0 +1,1766 @@ +"""Verified fake tests for the Model Target Web API.""" + +import base64 +import dataclasses +import io +import json +import textwrap +import zipfile +from http import HTTPMethod, HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +import requests +from beartype import beartype +from vws.response import Response + +from mock_vws import MockVWS, ModelTargetGenerationFailure +from mock_vws.model_target import ModelTargetDataset, ModelTargetDatasetType +from tests.mock_vws.fixtures.model_target_prepared_requests import ( + MODEL_TARGET_DATASET_UUID, + credentials_for_backend, + get_access_token, +) +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend +from tests.mock_vws.utils import ModelTargetEndpoint +from tests.mock_vws.utils.assertions import assert_valid_date_header + +_VWS_HOST = "https://vws.vuforia.com" +_MOCK_BEARER_TOKEN = "eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" + + +_VIEW: dict[str, Any] = { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, +} + + +@beartype +def _dataset_request(*, cad_data_url: str) -> dict[str, Any]: + """Return a standard Model Target dataset request.""" + return { + "name": f"dataset-{uuid4().hex}", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": cad_data_url, + "views": [_VIEW], + }, + ], + } + + +@beartype +def _cad_data_blob() -> str: + """Return a base64-encoded zipped model for inline CAD data.""" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(file=zip_buffer, mode="w") as zip_file: + zip_file.writestr( + zinfo_or_arcname="model.gltf", + data=json.dumps(obj={"asset": {"version": "2.0"}}), + ) + return base64.b64encode(s=zip_buffer.getvalue()).decode(encoding="ascii") + + +@beartype +def _blob_dataset_request() -> dict[str, Any]: + """Return a standard dataset request with inline CAD data.""" + return { + "name": f"dataset-{uuid4().hex}", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataBlob": _cad_data_blob(), + "cadDataFormat": "ZIP", + "views": [_VIEW], + }, + ], + } + + +_MODEL: dict[str, Any] = { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [_VIEW], +} + +_MODEL_WITHOUT_CAD_DATA: dict[str, Any] = { + key: value for key, value in _MODEL.items() if key != "cadDataUrl" +} + +_EMPTY_MODEL: dict[str, Any] = {} + +_EMPTY_VIEW: dict[str, Any] = {} + +_EMPTY_GUIDE_VIEW_POSITION: list[Any] = [] + +_EMPTY_GUIDE_VIEW_POSITION_OBJECT: dict[str, Any] = {} + +_UNAUTHENTICATED_DATASET_REQUEST: dict[str, Any] = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [_MODEL], +} + +_STATE_CONFIGURATION = json.dumps( + obj={ + "version": "1.0", + "default_state": "assembled", + "states": { + "assembled": {"base_scene": 0}, + "disassembled": {"base_scene": 0}, + }, + }, +) + + +@beartype +def _assert_oauth2_error( + *, + response: requests.Response, + status_code: HTTPStatus, + body: dict[str, str], +) -> None: + """Assert an OAuth2 error response.""" + assert response.status_code == status_code + assert response.json() == body + + +@beartype +def _assert_model_target_error( + *, + response: Response, + status_code: HTTPStatus, + code: str, + message: str, + target: str, +) -> None: + """Assert a Model Target Web API error response with the legacy + shape. + """ + assert response.status_code == status_code + assert json.loads(s=response.text) == { + "error": { + "code": code, + "message": message, + "target": target, + }, + } + + +@beartype +def _assert_load_balancer_bad_request(*, response: Response) -> None: + """Assert the ``BAD_REQUEST`` response from the load balancer. + + The load balancer in front of Vuforia rejects some requests before + they reach an API, with an HTML error page rather than a Model Target + Web API error body. + """ + assert response.status_code == HTTPStatus.BAD_REQUEST + assert_valid_date_header(response=response) + expected_response_text = textwrap.dedent( + text="""\ + \r + 400 Bad Request\r + \r +

400 Bad Request

\r + \r + \r + """, + ) + assert response.text == expected_response_text + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Content-Type": "text/html", + "Connection": "close", + "Server": "awselb/2.0", + "Date": response.headers["Date"], + } + + +@beartype +def _assert_unknown_dataset(*, response: Response) -> None: + """Assert a NOT_FOUND error for the unknown dataset UUID which the + prepared requests use. + + The body-less Model Target endpoints ignore any request body, so a + request with a valid bearer token and an unexpected or malformed body + reaches the dataset lookup. + """ + assert response.status_code == HTTPStatus.NOT_FOUND + error = json.loads(s=response.text)["error"] + assert error["code"] == "NOT_FOUND" + assert error["message"] == ( + "Could not find a model-view database with uuid " + f"{MODEL_TARGET_DATASET_UUID}" + ) + # The user-id portion is per-account in real Vuforia, so check only + # the stable prefix. + assert error["target"].startswith("userId:") + + +@beartype +def _access_token_for_backend(*, backend: VuforiaBackend) -> str: + """Return a valid access token for the chosen backend.""" + credentials = credentials_for_backend(backend=backend) + return get_access_token(credentials=credentials, backend=backend) + + +@pytest.mark.usefixtures("verify_model_target_mock_vuforia") +class TestAuthentication: + """Tests for Model Target Web API authentication. + + Bearer token concerns which apply to every Model Target endpoint are + covered by ``TestAuthorizationHeader``, via the + ``model_target_endpoint`` fixture. + """ + + @staticmethod + @pytest.mark.parametrize( + argnames=("auth", "data", "status_code", "body"), + argvalues=[ + pytest.param( + None, + {"grant_type": "client_credentials"}, + HTTPStatus.UNAUTHORIZED, + { + "error": "invalid_request", + "error_description": ( + "Missing or invalid authorization header" + ), + }, + id="missing-basic-auth", + ), + pytest.param( + ("invalid-client-id", "invalid-client-secret"), + {"grant_type": "client_credentials"}, + HTTPStatus.UNAUTHORIZED, + {"error": "invalid_client"}, + id="invalid-client", + ), + pytest.param( + ("invalid-client-id", "invalid-client-secret"), + {"grant_type": "unsupported"}, + HTTPStatus.BAD_REQUEST, + {"error": "unsupported_grant_type"}, + id="unsupported-grant-type", + ), + ], + ) + def test_invalid_oauth2_token_request( + *, + auth: tuple[str, str] | None, + data: dict[str, str], + status_code: HTTPStatus, + body: dict[str, str], + ) -> None: + """Invalid OAuth2 token requests are rejected.""" + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + auth=auth, + data=data, + timeout=30, + ) + + _assert_oauth2_error( + response=response, + status_code=status_code, + body=body, + ) + + +@pytest.mark.usefixtures("verify_model_target_mock_vuforia") +class TestAuthorizationHeader: + """Tests for the ``Authorization`` header on every Model Target + endpoint. + + These mirror the cross-cutting tests which the ``endpoint`` fixture + supports for the VWS and Query APIs. The Model Target Web API uses + OAuth2 bearer tokens rather than HMAC signatures, so the VWS + ``Authorization`` and ``Date`` header concerns do not apply to it, + and it gets its own smaller set of concerns via the + ``model_target_endpoint`` fixture. The OAuth2 token endpoint is not + in that fixture because it takes HTTP Basic credentials rather than + a bearer token. + """ + + @staticmethod + def test_missing( + *, + model_target_endpoint: ModelTargetEndpoint, + ) -> None: + """An ``UNAUTHORIZED`` response is returned when no + ``Authorization`` header is given. + """ + response = model_target_endpoint.send() + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + ) + + @staticmethod + @pytest.mark.parametrize( + argnames=("authorization", "message"), + argvalues=[ + pytest.param("Basic abc", "no Bearer token", id="not-bearer"), + pytest.param("Bearer ", "no Bearer token", id="blank"), + pytest.param( + "Bearer invalid-token", + "Invalid JWT serialization: Missing dot delimiter(s)", + id="malformed", + ), + pytest.param( + "Bearer ..", + "Invalid unsecured/JWS/JWE header: Invalid JSON object", + id="invalid-header-json", + ), + pytest.param( + "Bearer e30.e30.signature", + 'Missing "alg" in header JSON object', + id="missing-algorithm", + ), + pytest.param( + "Bearer eyJhbGciOiJub25lIn0.e30.", + ( + "Unsecured (plain) JWTs are rejected, extend class to " + "handle" + ), + id="unsecured", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.%.signature", + "Payload of JWS object is not a valid JSON object", + id="payload-not-base64", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9..signature", + "Payload of JWS object is not a valid JSON object", + id="blank-payload", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.InZhbHVlIg.signature", + "Payload of JWS object is not a valid JSON object", + id="payload-not-json-object", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.e30.", + "The signature must not be empty", + id="blank-signature", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.e30.%", + "Signed JWT rejected: Invalid signature", + id="signature-not-base64", + ), + ], + ) + def test_invalid_bearer_token( + *, + model_target_endpoint: ModelTargetEndpoint, + authorization: str, + message: str, + ) -> None: + """Invalid bearer tokens are rejected.""" + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": authorization, + }, + ) + + response = new_endpoint.send() + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message=message, + target="jwt", + ) + + +@pytest.mark.usefixtures("verify_model_target_mock_vuforia") +class TestContentLength: + """Tests for the ``Content-Length`` header on every Model Target + endpoint. + + These mirror the cross-cutting tests which the ``endpoint`` fixture + supports for the VWS and Query APIs. + + A ``Content-Length`` header which is too large is not covered, for the + same reason as it is not covered for the VWS API: real Vuforia waits + for the body it was promised before timing out, which takes too long + to run in a test. + """ + + @staticmethod + def test_not_integer( + *, + model_target_endpoint: ModelTargetEndpoint, + ) -> None: + """A ``Content-Length`` header which is not an integer is rejected + by the load balancer in front of Vuforia, before any bearer token + is looked at. + """ + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Content-Length": "0.4", + }, + ) + + response = new_endpoint.send() + + _assert_load_balancer_bad_request(response=response) + + @staticmethod + def test_not_integer_oauth2_token() -> None: + """The OAuth2 token endpoint is behind the same load balancer. + + It is not in the ``model_target_endpoint`` fixture because it takes + HTTP Basic credentials rather than a bearer token. + """ + endpoint = ModelTargetEndpoint( + base_url=_VWS_HOST, + path_url="/oauth2/token", + method=HTTPMethod.POST, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": "0.4", + }, + data=b"grant_type=client_credentials", + takes_json_body=False, + ) + + response = endpoint.send() + + _assert_load_balancer_bad_request(response=response) + + @staticmethod + def test_too_small( + *, + model_target_endpoint: ModelTargetEndpoint, + ) -> None: + """A ``Content-Length`` header which is too small truncates the + body, and the request is still rejected for having no bearer + token. + + The Model Target Web API does not sign the request body, so unlike + the VWS API it has no reason to notice the truncation before it + looks at the ``Authorization`` header. + """ + if not model_target_endpoint.takes_json_body: + return + + content_length = len(model_target_endpoint.data) - 1 + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Content-Length": str(object=content_length), + }, + ) + + response = new_endpoint.send() + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + ) + + +class TestInvalidJson: + """Tests for giving Model Target endpoints bodies which are not + valid JSON objects. + """ + + @staticmethod + def test_wrong_content_type( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + model_target_endpoint: ModelTargetEndpoint, + ) -> None: + """Requests without a JSON content type are rejected with 415 by + endpoints which read a body, and are unaffected elsewhere. + """ + access_token = _access_token_for_backend( + backend=verify_model_target_mock_vuforia, + ) + new_headers = { + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + } + new_headers.pop("Content-Type", None) + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers=new_headers, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + _assert_unknown_dataset(response=response) + return + + assert response.status_code == HTTPStatus.UNSUPPORTED_MEDIA_TYPE + error = json.loads(s=response.text)["error"] + assert error["code"] == "ERROR" + assert error["message"] == ( + "Expecting text/json or application/json body" + ) + assert "target" not in error + + @staticmethod + def test_invalid_json( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + model_target_endpoint: ModelTargetEndpoint, + ) -> None: + """Malformed JSON bodies are rejected with 400 by endpoints which + read a body, and are ignored elsewhere. + """ + access_token = _access_token_for_backend( + backend=verify_model_target_mock_vuforia, + ) + content = b"{" + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + "Content-Length": str(object=len(content)), + }, + data=content, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + _assert_unknown_dataset(response=response) + return + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = json.loads(s=response.text)["error"] + assert error["code"] == "ERROR" + assert error["message"].startswith("Invalid Json") + assert "target" not in error + + @staticmethod + def test_body_not_utf_8( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + model_target_endpoint: ModelTargetEndpoint, + ) -> None: + """Bodies which are not valid UTF-8 are rejected with 400 by + endpoints which read a body, and are ignored elsewhere. + """ + access_token = _access_token_for_backend( + backend=verify_model_target_mock_vuforia, + ) + content = b"\xff{}" + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + "Content-Length": str(object=len(content)), + }, + data=content, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + _assert_unknown_dataset(response=response) + return + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = json.loads(s=response.text)["error"] + assert error["code"] == "ERROR" + assert error["message"].startswith("Invalid Json") + assert "target" not in error + + @staticmethod + @pytest.mark.parametrize( + argnames="body", + argvalues=[ + pytest.param("[]", id="array"), + pytest.param('"dataset"', id="string"), + pytest.param("1", id="number"), + pytest.param("true", id="boolean"), + pytest.param("null", id="null"), + ], + ) + def test_body_not_json_object( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + model_target_endpoint: ModelTargetEndpoint, + body: str, + ) -> None: + """JSON bodies which are not objects are missing every field on + endpoints which read a body, and are ignored elsewhere. + """ + access_token = _access_token_for_backend( + backend=verify_model_target_mock_vuforia, + ) + content = body.encode(encoding="utf-8") + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + "Content-Length": str(object=len(content)), + }, + data=content, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + _assert_unknown_dataset(response=response) + return + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = json.loads(s=response.text)["error"] + assert error["code"] == "BAD_REQUEST" + assert error["message"] == ( + f"Validation error for request {error['target']}" + ) + actual_messages = {detail["message"] for detail in error["details"]} + assert actual_messages == { + "/models: element is required", + "/name: element is required", + "/targetSdk: element is required", + } + for detail in error["details"]: + assert detail["code"] == "VALIDATION_ERROR" + + +@pytest.mark.usefixtures("verify_model_target_mock_vuforia") +class TestErrorResponses: + """Verified fake tests for Model Target Web API error responses.""" + + @staticmethod + @pytest.mark.parametrize( + argnames="authorization", + argvalues=[ + pytest.param("Basic not-base64!", id="invalid-base64"), + pytest.param( + ( + "Basic " + + base64.b64encode(s=b"client-id-without-secret").decode() + ), + id="missing-separator", + ), + ], + ) + def test_invalid_basic_auth_header(*, authorization: str) -> None: + """Malformed OAuth2 Basic auth headers are rejected.""" + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + headers={"Authorization": authorization}, + data={"grant_type": "client_credentials"}, + timeout=30, + ) + + _assert_oauth2_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + body={ + "error": "invalid_request", + "error_description": "Missing or invalid authorization header", + }, + ) + + @staticmethod + @pytest.mark.parametrize( + argnames=("body", "expected_messages"), + argvalues=[ + pytest.param( + {}, + { + "/models: element is required", + "/name: element is required", + "/targetSdk: element is required", + }, + id="empty-body", + ), + pytest.param( + { + "name": "dataset-name", + "targetSdk": "10.18", + "models": "model", + }, + {"/models: error.expected.jsarray"}, + id="models-not-list", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [], + }, + {"exactly one model should be provided"}, + id="standard-zero-models", + ), + pytest.param( + {**_UNAUTHENTICATED_DATASET_REQUEST, "name": 1}, + {"/name: error.expected.jsstring"}, + id="name-not-string", + ), + pytest.param( + {**_UNAUTHENTICATED_DATASET_REQUEST, "targetSdk": ["10.18"]}, + {"/targetSdk: error.expected.jsstring"}, + id="target-sdk-not-string", + ), + pytest.param( + {"name": None, "targetSdk": None, "models": "model"}, + { + "/models: error.expected.jsarray", + "/name: error.expected.jsstring", + "/targetSdk: error.expected.jsstring", + }, + id="multiple-type-errors", + ), + pytest.param( + {**_UNAUTHENTICATED_DATASET_REQUEST, "models": ["model"]}, + {"/models(0): error.expected.jsobject"}, + id="model-not-object", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + *_UNAUTHENTICATED_DATASET_REQUEST["models"], + "model", + ], + }, + {"/models(1): error.expected.jsobject"}, + id="second-model-not-object", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [_EMPTY_MODEL], + }, + { + ( + "/models(0): one and only one of cadDataUrl and " + "cadDataBlob is required" + ), + "/models(0)/name: element is required", + }, + id="model-missing-fields", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [_MODEL_WITHOUT_CAD_DATA], + }, + { + ( + "/models(0): one and only one of cadDataUrl and " + "cadDataBlob is required" + ), + }, + id="model-without-cad-data", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "cadDataBlob": "ZmFrZQ==", + "cadDataFormat": "ZIP", + }, + ], + }, + { + ( + "/models(0): one and only one of cadDataUrl and " + "cadDataBlob is required" + ), + }, + id="model-with-both-cad-data-sources", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "cadDataUrl": 1, + }, + ], + }, + {"/models(0)/cadDataUrl: error.expected.jsstring"}, + id="model-cad-data-url-not-string", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL_WITHOUT_CAD_DATA, + "cadDataBlob": 1, + "cadDataFormat": "ZIP", + }, + ], + }, + {"/models(0)/cadDataBlob: error.expected.jsstring"}, + id="model-cad-data-blob-not-string", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "cadDataFormat": 1}], + }, + {"/models(0)/cadDataFormat: error.expected.jsstring"}, + id="model-cad-data-format-not-string", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "cadDataFormat": "gltf"}], + }, + {"/models(0)/cadDataFormat: error.expected.validenum"}, + id="model-cad-data-format-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "name": None}], + }, + {"/models(0)/name: error.expected.jsstring"}, + id="model-name-not-string", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "simplify": 1}], + }, + {"/models(0)/simplify: error.expected.jsstring"}, + id="model-simplify-not-string", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "simplify": "sometimes"}], + }, + {"/models(0)/simplify: error.expected.validenum"}, + id="model-simplify-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "automaticColoring": "sometimes"}], + }, + {"/models(0)/automaticColoring: error.expected.validenum"}, + id="model-automatic-coloring-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "motionHint": "still"}], + }, + {"/models(0)/motionHint: error.expected.validenum"}, + id="model-motion-hint-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "optimizeTrackingFor": "cars"}], + }, + {"/models(0)/optimizeTrackingFor: error.expected.validenum"}, + id="model-optimize-tracking-for-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "trackingMode": "boat"}], + }, + {"/models(0)/trackingMode: error.expected.validenum"}, + id="model-tracking-mode-not-in-enum", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "motionHint": "still", + "simplify": "sometimes", + }, + ], + }, + { + "/models(0)/motionHint: error.expected.validenum", + "/models(0)/simplify: error.expected.validenum", + }, + id="model-multiple-enum-errors", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "views": "view-name"}], + }, + {"/models(0)/views: error.expected.jsarray"}, + id="model-views-not-array", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "views": ["view-name"]}], + }, + {"/models(0)/views(0): error.expected.jsobject"}, + id="view-not-object", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "views": [_EMPTY_VIEW]}], + }, + { + ( + "/models(0)/views(0)/guideViewPosition: " + "element is required" + ), + "/models(0)/views(0)/name: element is required", + }, + id="view-missing-fields", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + {**_MODEL, "views": [{**_VIEW, "name": 1}]}, + ], + }, + {"/models(0)/views(0)/name: error.expected.jsstring"}, + id="view-name-not-string", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "views": [ + _VIEW, + { + **_VIEW, + "guideViewPosition": ( + _EMPTY_GUIDE_VIEW_POSITION + ), + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(1)/guideViewPosition: " + "error.expected.jsobject" + ), + }, + id="view-guide-view-position-not-object", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "views": [ + { + **_VIEW, + "guideViewPosition": ( + _EMPTY_GUIDE_VIEW_POSITION_OBJECT + ), + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/rotation: " + "element is required" + ), + ( + "/models(0)/views(0)/guideViewPosition/translation: " + "element is required" + ), + }, + id="guide-view-position-missing-fields", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "views": [ + { + **_VIEW, + "guideViewPosition": { + "rotation": "0,0,0,1", + "translation": [0, 0, 5], + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/rotation: " + "error.expected.jsarray" + ), + }, + id="guide-view-position-rotation-not-array", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "views": [ + { + **_VIEW, + "guideViewPosition": { + "rotation": [0, 0, 0, 1], + "translation": 5, + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/translation: " + "error.expected.jsarray" + ), + }, + id="guide-view-position-translation-not-array", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "views": [ + { + **_VIEW, + "guideViewPosition": { + "rotation": [0, "0", 0, 1], + "translation": [0, 0, 5], + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/rotation(1): " + "error.expected.jsnumber" + ), + }, + id="guide-view-position-rotation-element-not-number", + ), + pytest.param( + { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "views": [ + { + **_VIEW, + "guideViewPosition": { + "rotation": [0, 0, 0, 1], + "translation": [0, 0, True], + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/" + "translation(2): error.expected.jsnumber" + ), + }, + id="guide-view-position-translation-element-not-number", + ), + ], + ) + def test_invalid_dataset_request( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + body: dict[str, object], + expected_messages: set[str], + ) -> None: + """Invalid standard dataset creation requests are rejected.""" + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = get_access_token( + credentials=credentials, + backend=verify_model_target_mock_vuforia, + ) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert error["message"] == ( + f"Validation error for request {error['target']}" + ) + actual_messages = {detail["message"] for detail in error["details"]} + assert actual_messages == expected_messages + for detail in error["details"]: + assert detail["code"] == "VALIDATION_ERROR" + + @staticmethod + @pytest.mark.parametrize( + argnames=("method", "path"), + argvalues=[ + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/status", + id="status", + ), + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/dataset", + id="download", + ), + pytest.param( + HTTPMethod.DELETE, + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}", + id="delete", + ), + ], + ) + def test_unknown_dataset( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + method: HTTPMethod, + path: str, + ) -> None: + """Unknown datasets are rejected with a NOT_FOUND error.""" + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = get_access_token( + credentials=credentials, + backend=verify_model_target_mock_vuforia, + ) + response = requests.request( + method=method, + url=f"{_VWS_HOST}{path}", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + error = response.json()["error"] + assert error["code"] == "NOT_FOUND" + assert error["message"] == ( + "Could not find a model-view database with uuid " + f"{MODEL_TARGET_DATASET_UUID}" + ) + # The user-id portion is per-account in real Vuforia, so check only + # the stable prefix. + assert error["target"].startswith("userId:") + + +class TestMockOnlyErrors: + """Mock-only Model Target Web API error paths. + + These cases cannot easily be verified against real Vuforia with the + currently available test account and are kept mock-only by design. + """ + + @staticmethod + @pytest.mark.parametrize( + argnames="dataset_path", + argvalues=[ + pytest.param("/modeltargets/datasets", id="standard"), + pytest.param( + "/modeltargets/advancedDatasets", + id="advanced", + ), + ], + ) + @pytest.mark.parametrize( + argnames="view_updates", + argvalues=[ + pytest.param({}, id="all-states"), + pytest.param( + {"states": ["assembled"]}, + id="selected-states", + ), + ], + ) + def test_state_based_dataset( + *, + model_target_mock_only_vuforia: VuforiaBackend, + dataset_path: str, + view_updates: dict[str, object], + ) -> None: + """State-Based Model Target fields survive a dataset round + trip. + """ + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + "stateBasedConfigurationJsonString": ( + _STATE_CONFIGURATION + ), + "views": [{**_VIEW, **view_updates}], + }, + ], + } + access_token = _access_token_for_backend( + backend=model_target_mock_only_vuforia, + ) + headers = {"Authorization": f"Bearer {access_token}"} + create_response = requests.post( + url=f"{_VWS_HOST}{dataset_path}", + headers=headers, + json=body, + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + dataset_uuid = create_response.json()["uuid"] + delete_response = requests.delete( + url=f"{_VWS_HOST}{dataset_path}/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert delete_response.status_code == HTTPStatus.OK + + @staticmethod + @pytest.mark.parametrize( + argnames=("model_updates", "view_updates", "expected_message"), + argvalues=[ + pytest.param( + {"stateBasedConfigurationJsonString": 1}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString: " + "error.expected.jsstring" + ), + id="configuration-not-string", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "{"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString: " + "error.expected.validjson" + ), + id="configuration-not-json", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "{}"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString/states: " + "error.expected.jsobject" + ), + id="configuration-states-not-object", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "[]"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString/states: " + "error.expected.jsobject" + ), + id="configuration-not-object", + ), + pytest.param( + {"stateBasedConfigurationJsonString": _STATE_CONFIGURATION}, + {"states": "assembled"}, + "/models(0)/views(0)/states: error.expected.jsarray", + id="view-states-not-array", + ), + pytest.param( + {"stateBasedConfigurationJsonString": _STATE_CONFIGURATION}, + {"states": ["assembled", 1]}, + ("/models(0)/views(0)/states(1): error.expected.jsstring"), + id="view-state-not-string", + ), + pytest.param( + {"stateBasedConfigurationJsonString": _STATE_CONFIGURATION}, + {"states": ["unknown"]}, + ("/models(0)/views(0)/states(0): error.expected.validenum"), + id="view-state-not-declared", + ), + pytest.param( + {}, + {"states": ["assembled"]}, + ( + "/models(0)/stateBasedConfigurationJsonString: element " + "is required when view states are given" + ), + id="view-states-without-configuration", + ), + ], + ) + def test_invalid_state_based_dataset( + *, + model_target_mock_only_vuforia: VuforiaBackend, + model_updates: dict[str, object], + view_updates: dict[str, object], + expected_message: str, + ) -> None: + """Invalid State-Based Model Target fields are rejected.""" + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **_MODEL, + **model_updates, + "views": [{**_VIEW, **view_updates}], + }, + ], + } + access_token = _access_token_for_backend( + backend=model_target_mock_only_vuforia, + ) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + expected_message, + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + @staticmethod + def test_advanced_model_count_exceeds_limit() -> None: + """Advanced dataset requests with too many models are rejected. + + Real Vuforia returns a 403 for the currently available test account + because the account lacks the advanced-dataset scope, so the + validation-error shape cannot be observed end-to-end. The mock + therefore enforces the documented advanced-dataset model count + limit on its own. + """ + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [*_UNAUTHENTICATED_DATASET_REQUEST["models"]] * 21, + } + with MockVWS(): + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + json=body, + timeout=30, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + @staticmethod + def test_advanced_realistic_appearance_not_in_enum() -> None: + """Advanced dataset requests with a ``realisticAppearance`` value + outside the documented enumeration are rejected. + + The Model Target OpenAPI specification documents + ``realisticAppearance`` as a model field for advanced datasets + only, so standard dataset creation does not validate it. This is + mock-only because the available test account lacks the + advanced-dataset scope, so real Vuforia rejects the request with a + 403 before validating the body. + """ + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**_MODEL, "realisticAppearance": "yes"}], + } + headers = {"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"} + with MockVWS(): + advanced_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", + headers=headers, + json=body, + timeout=30, + ) + standard_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers=headers, + json=body, + timeout=30, + ) + + assert advanced_response.status_code == HTTPStatus.BAD_REQUEST + error = advanced_response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + "/models(0)/realisticAppearance: error.expected.validenum", + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + assert standard_response.status_code == HTTPStatus.CREATED + + @staticmethod + def test_oauth2_token_body_not_utf_8( + *, + model_target_mock_only_vuforia: VuforiaBackend, + ) -> None: + """An OAuth2 token request with a body which is not valid UTF-8 is + treated as one which does not name a grant type. + + Mock-only because the real response to a form body which cannot be + decoded has not been observed. + """ + credentials = credentials_for_backend( + backend=model_target_mock_only_vuforia, + ) + + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + auth=(credentials.client_id, credentials.client_secret), + data=b"\xff", + timeout=30, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json()["token_type"] == "bearer" + + @staticmethod + def test_processing_dataset_cannot_be_downloaded() -> None: + """A dataset cannot be downloaded while it is still processing. + + Mock-only because exercising this against real Vuforia would require + creating a dataset on every test run; the mock lets us drive the + processing window deterministically. + """ + with MockVWS(processing_time_seconds=60): + create_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + json=_UNAUTHENTICATED_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + response = requests.get( + url=( + f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}/dataset" + ), + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + timeout=30, + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + error = response.json()["error"] + assert error["code"] == "UNSUPPORTED_STATE" + assert error["message"] == ( + f"Training status for dataset {dataset_uuid} is " + "not-started != done" + ) + assert error["target"] == dataset_uuid + + @staticmethod + def test_failed_dataset_cannot_be_downloaded() -> None: + """A dataset which failed generation cannot be downloaded, and the + error reports the failed training status rather than the + ``not-started`` status which a still-processing dataset reports. + + Mock-only because a generation failure cannot be provoked on demand + against real Vuforia, so the training status name it reports for a + failed dataset has not been observed. + """ + failure = ModelTargetGenerationFailure(message="CAD model is invalid") + with MockVWS( + processing_time_seconds=0, + model_target_generation_failure=failure, + ): + create_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + json=_UNAUTHENTICATED_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + status_response = requests.get( + url=f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}/status", + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + timeout=30, + ) + response = requests.get( + url=( + f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}/dataset" + ), + headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + timeout=30, + ) + + assert status_response.json()["status"] == "failed" + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + error = response.json()["error"] + assert error["code"] == "UNSUPPORTED_STATE" + assert error["message"] == ( + f"Training status for dataset {dataset_uuid} is failed != done" + ) + assert error["target"] == dataset_uuid + + @staticmethod + @pytest.mark.parametrize( + argnames=("created_path", "other_path"), + argvalues=[ + pytest.param( + "/modeltargets/datasets", + "/modeltargets/advancedDatasets", + id="standard-dataset-via-advanced-routes", + ), + pytest.param( + "/modeltargets/advancedDatasets", + "/modeltargets/datasets", + id="advanced-dataset-via-standard-routes", + ), + ], + ) + def test_dataset_is_not_visible_to_the_other_dataset_type( + *, + created_path: str, + other_path: str, + ) -> None: + """A dataset is not reachable through the other type's routes. + + Standard and advanced datasets are separate resources in real + Vuforia, with separate OAuth scopes. This is mock-only because the + available test account lacks the advanced-dataset scope, so real + Vuforia rejects advanced routes with a 403 before looking a dataset + up. + """ + headers = {"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"} + with MockVWS(): + create_response = requests.post( + url=f"{_VWS_HOST}{created_path}", + headers=headers, + json=_UNAUTHENTICATED_DATASET_REQUEST, + timeout=30, + ) + assert create_response.status_code == HTTPStatus.CREATED + dataset_uuid = create_response.json()["uuid"] + + other_responses = [ + requests.get( + url=f"{_VWS_HOST}{other_path}/{dataset_uuid}/status", + headers=headers, + timeout=30, + ), + requests.get( + url=f"{_VWS_HOST}{other_path}/{dataset_uuid}/dataset", + headers=headers, + timeout=30, + ), + requests.delete( + url=f"{_VWS_HOST}{other_path}/{dataset_uuid}", + headers=headers, + timeout=30, + ), + ] + + # The dataset survives the delete attempt made through the other + # type's routes. + own_status_response = requests.get( + url=f"{_VWS_HOST}{created_path}/{dataset_uuid}/status", + headers=headers, + timeout=30, + ) + + for response in other_responses: + assert response.status_code == HTTPStatus.NOT_FOUND + error = response.json()["error"] + assert error["code"] == "NOT_FOUND" + assert error["message"] == ( + "Could not find a model-view database with uuid " + f"{dataset_uuid}" + ) + assert error["target"].startswith("userId:") + + assert own_status_response.status_code == HTTPStatus.OK + + +class TestStandardDataset: + """Tests for standard Model Target datasets.""" + + @staticmethod + def test_create_status_and_delete( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """A standard Model Target dataset can be created and deleted.""" + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = get_access_token( + credentials=credentials, + backend=verify_model_target_mock_vuforia, + ) + headers = {"Authorization": f"Bearer {access_token}"} + dataset_uuid: str | None = None + + try: + create_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers=headers, + json=_dataset_request(cad_data_url=credentials.cad_data_url), + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + create_response_json: dict[str, Any] = json.loads( + s=create_response.text, + ) + dataset_uuid_value = create_response_json["uuid"] + assert isinstance(dataset_uuid_value, str) + dataset_uuid = dataset_uuid_value + + status_response = requests.get( + url=( + f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + + assert status_response.status_code == HTTPStatus.OK + status_response_json: dict[str, Any] = json.loads( + s=status_response.text, + ) + assert status_response_json["status"] in { + "processing", + "done", + "failed", + } + assert isinstance(status_response_json["createdAt"], str) + finally: + if dataset_uuid is not None: # pragma: no branch + delete_response = requests.delete( + url=f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert delete_response.status_code in { + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + } + + @staticmethod + def test_create_with_cad_data_blob( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """A dataset can be created with inline CAD data.""" + credentials = credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = get_access_token( + credentials=credentials, + backend=verify_model_target_mock_vuforia, + ) + headers = {"Authorization": f"Bearer {access_token}"} + + create_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers=headers, + json=_blob_dataset_request(), + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + create_response_json: dict[str, Any] = json.loads( + s=create_response.text, + ) + dataset_uuid = create_response_json["uuid"] + assert isinstance(dataset_uuid, str) + + # There is nothing to assert between creating and deleting the + # dataset, so the delete does not need a ``finally`` block to avoid + # leaving a dataset behind on real Vuforia. + delete_response = requests.delete( + url=f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert delete_response.status_code in { + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + } + + +class TestModelTargetDatasetStatus: + """Tests for Model Target dataset status response bodies.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=("processing_time_seconds", "status", "time_field"), + argvalues=[ + pytest.param(3600.0, "processing", "eta", id="processing"), + pytest.param(0.0, "done", "completedAt", id="done"), + ], + ) + def test_status_uses_matching_time_field( + *, + processing_time_seconds: float, + status: str, + time_field: str, + ) -> None: + """Each status includes only its matching timestamp field.""" + dataset = ModelTargetDataset( + request_body={}, + dataset_type=ModelTargetDatasetType.STANDARD, + processing_time_seconds=processing_time_seconds, + generation_failure=None, + generation_warning=None, + uuid_="dataset-uuid", + ) + + body = dataset.status_body() + + assert body["status"] == status + assert body["uuid"] == "dataset-uuid" + assert {"eta", "completedAt"} & body.keys() == {time_field} diff --git a/tests/mock_vws/test_query.py b/tests/mock_vws/test_query.py index d04194406..68eef4810 100644 --- a/tests/mock_vws/test_query.py +++ b/tests/mock_vws/test_query.py @@ -1,49 +1,103 @@ -""" -Tests for the mock of the query endpoint. +"""Tests for the mock of the query endpoint. -https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. +https://developer.vuforia.com/library/web-api/vuforia-query-web-api. """ import base64 import calendar +import copy import datetime import io +import json +import re +import textwrap import time import uuid -from http import HTTPStatus -from typing import Any, Dict, Union +from http import HTTPMethod, HTTPStatus +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin +from zoneinfo import ZoneInfo import pytest import requests -from backports.zoneinfo import ZoneInfo +from dirty_equals import IsInstance from PIL import Image -from requests import Response -from requests_mock import POST +from tenacity import Retrying +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed from urllib3.filepost import encode_multipart_formdata -from vws import VWS +from vws import VWS, CloudRecoService +from vws.exceptions.cloud_reco_exceptions import ( + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, +) +from vws.exceptions.custom_exceptions import RequestEntityTooLargeError from vws.reports import TargetStatuses +from vws.response import Response from vws_auth_tools import authorization_header, rfc_1123_date -from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase -from tests.mock_vws.utils import make_image_file +from mock_vws.database import CloudDatabase +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend +from tests.mock_vws.utils import ( + make_decompression_bomb_image_file, + make_image_file, +) from tests.mock_vws.utils.assertions import ( assert_query_success, - assert_valid_date_header, assert_valid_transaction_id, assert_vwq_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors + +if TYPE_CHECKING: + from collections.abc import Iterable + +VWQ_HOST = "https://cloudreco.vuforia.com" + +_JETTY_CONTENT_TYPE_ERROR = textwrap.dedent( + text="""\ + + + + Error 400 Bad Request + + +

HTTP ERROR 400 Bad Request

+ + + + +
URI:http://cloudreco.vuforia.com/v1/query
STATUS:400
MESSAGE:Bad Request
+
Powered by Jetty:// 12.0.20
+ + + + """, +) -VWQ_HOST = 'https://cloudreco.vuforia.com' +_JETTY_VERSION_RE = re.compile(pattern=r"Powered by Jetty:// [\d.]+") + +_NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR = textwrap.dedent( + text="""\ + \r + 413 Request Entity Too Large\r + \r +

413 Request Entity Too Large

\r +
nginx
\r + \r + \r + """, +) -def query( - vuforia_database: VuforiaDatabase, - body: Dict[str, Any], +def _query( + *, + vuforia_database: CloudDatabase, + body: dict[str, Any], ) -> Response: - """ - Make a request to the endpoint to make an image recognition query. + """Make a request to the endpoint to make an image recognition query. Args: vuforia_database: The credentials to use to connect to @@ -54,9 +108,9 @@ def query( The response returned by the API. """ date = rfc_1123_date() - request_path = '/v1/query' - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -66,57 +120,107 @@ def query( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, } - vwq_host = 'https://cloudreco.vuforia.com' - response = requests.request( + vwq_host = "https://cloudreco.vuforia.com" + requests_response = requests.request( method=method, url=urljoin(base=vwq_host, url=request_path), headers=headers, data=content, + timeout=30, ) - return response + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + return vws_response -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestContentType: - """ - Tests for the Content-Type header. - """ + """Tests for the Content-Type header.""" + @staticmethod @pytest.mark.parametrize( - 'content_type', - [ - 'text/html', - '', + argnames=( + "content_type", + "resp_status_code", + "resp_content_type", + "resp_cache_control", + "resp_text", + ), + argvalues=[ + ( + "text/html", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + None, + None, + "", + ), + ( + "", + HTTPStatus.BAD_REQUEST, + "text/html;charset=iso-8859-1", + "must-revalidate,no-cache,no-store", + _JETTY_CONTENT_TYPE_ERROR, + ), + ( + "*/*", + HTTPStatus.BAD_REQUEST, + "text/plain;charset=utf-8", + None, + "Unable to get boundary for multipart", + ), + ( + "text/*", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + None, + None, + "", + ), + ( + "text/plain", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + None, + None, + "", + ), ], ) def test_incorrect_no_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, content_type: str, + resp_status_code: int, + resp_content_type: str | None, + resp_cache_control: str | None, + resp_text: str, ) -> None: - """ - If a Content-Type header which is not ``multipart/form-data``, an - ``UNSUPPORTED_MEDIA_TYPE`` response is given. - """ + """With bad Content-Type headers we get a variety of results.""" image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, _ = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -131,43 +235,66 @@ def test_incorrect_no_boundary( ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert response.text == '' + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + + handle_server_errors(response=vws_response) + + repl = "Powered by Jetty://" + sub = _JETTY_VERSION_RE.sub + actual = sub(repl=repl, string=requests_response.text) + expected = sub(repl=repl, string=resp_text) + assert actual == expected assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - content_type=None, + response=vws_response, + status_code=resp_status_code, + content_type=resp_content_type, + cache_control=resp_cache_control, + www_authenticate=None, + connection="keep-alive", ) + @staticmethod def test_incorrect_with_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - If a Content-Type header which is not ``multipart/form-data`` is given - with the correct boundary, an ``UNSUPPORTED_MEDIA_TYPE`` response is + If a Content-Type header which is not ``multipart/form-data`` is + given + with the correct boundary, an ``UNSUPPORTED_MEDIA_TYPE`` response + is given. """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST - content_type = 'text/html' + content_type = "text/html" access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -181,52 +308,65 @@ def test_incorrect_with_boundary( request_path=request_path, ) - _, boundary = content_type_header.split(';') + _, boundary = content_type_header.split(sep=";") - content_type = 'text/html; ' + boundary + content_type = "text/html; " + boundary headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert response.text == '' + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert not requests_response.text assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, content_type=None, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) + @staticmethod @pytest.mark.parametrize( - 'content_type', - [ - 'multipart/form-data', - 'multipart/form-data; extra', - 'multipart/form-data; extra=1', + argnames="content_type", + argvalues=[ + "multipart/form-data", + "multipart/form-data; extra", + "multipart/form-data; extra=1", ], ) def test_no_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, content_type: str, ) -> None: - """ - If no boundary is given, a ``BAD_REQUEST`` is returned. - """ + """If no boundary is given, a ``BAD_REQUEST`` is returned.""" image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, _ = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -236,49 +376,58 @@ def test_no_boundary( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - expected_text = ( - 'java.io.IOException: RESTEASY007550: ' - 'Unable to get boundary for multipart' + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, ) - assert response.text == expected_text + expected_text = "Unable to get boundary for multipart" + assert requests_response.text == expected_text assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.BAD_REQUEST, - content_type='text/html;charset=UTF-8', + content_type="text/plain;charset=utf-8", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) + @staticmethod def test_bogus_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: - """ - If a bogus boundary is given, a ``BAD_REQUEST`` is returned. - """ + """If a bogus boundary is given, a ``BAD_REQUEST`` is returned.""" image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, _ = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -288,50 +437,64 @@ def test_bogus_boundary( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': 'multipart/form-data; boundary=example_boundary', + "Authorization": authorization_string, + "Date": date, + "Content-Type": "multipart/form-data; boundary=example_boundary", } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - expected_text = ( - 'java.lang.RuntimeException: RESTEASY007500: ' - 'Could find no Content-Disposition header within part' + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, ) - assert response.text == expected_text + handle_server_errors(response=vws_response) + + expected_text = "No image." + assert requests_response.text == expected_text assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.BAD_REQUEST, - content_type='text/html;charset=UTF-8', + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) + @staticmethod def test_extra_section( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - If sections that are not the boundary section are given in the header, + If sections that are not the boundary section are given in the + header, that is fine. """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -341,107 +504,202 @@ def test_extra_section( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header + '; extra=1', + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header + "; extra=1", } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert_query_success(response=response) - assert response.json()['results'] == [] + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestSuccess: - """ - Tests for successful calls to the query endpoint. - """ + """Tests for successful calls to the query endpoint.""" + @staticmethod def test_no_results( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + cloud_reco_client: CloudRecoService, ) -> None: """ - When there are no matching images in the database, an empty list of + When there are no matching images in the database, an empty list + of results is returned. """ - image_content = high_quality_image.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] - assert_query_success(response=response) - assert response.json()['results'] == [] - - def test_match( - self, + @staticmethod + def test_match_exact( + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, vws_client: VWS, ) -> None: """ - If the exact image that was added is queried for, target data is shown. + If the exact high quality image that was added is queried for, + target + data is shown. """ - image_content = high_quality_image.getvalue() - metadata_encoded = base64.b64encode(b'example').decode('ascii') - name = 'example_name' + image_file = high_quality_image + image_content = image_file.getvalue() + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) + name = "example_name" target_id = vws_client.add_target( name=name, width=1, - image=high_quality_image, + image=image_file, active_flag=True, application_metadata=metadata_encoded, ) - approximate_target_created = calendar.timegm(time.gmtime()) + approximate_target_created = calendar.timegm(tuple=time.gmtime()) vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + body = {"image": ("image.jpeg", image_content, "image/jpeg")} - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - [result] = response.json()['results'] - assert result.keys() == {'target_id', 'target_data'} - assert result['target_id'] == target_id - target_data = result['target_data'] - assert target_data.keys() == { - 'application_metadata', - 'name', - 'target_timestamp', + response_json = json.loads(s=response.text) + (result,) = response_json["results"] + assert result == { + "target_id": target_id, + "target_data": { + "application_metadata": metadata_encoded, + "name": name, + "target_timestamp": IsInstance(expected_type=int), + }, } - assert target_data['application_metadata'] == metadata_encoded - assert target_data['name'] == name - target_timestamp = target_data['target_timestamp'] - assert isinstance(target_timestamp, int) + target_timestamp = int(result["target_data"]["target_timestamp"]) time_difference = abs(approximate_target_created - target_timestamp) - assert time_difference < 5 + max_time_difference = 5 + assert time_difference < max_time_difference + + @staticmethod + def test_low_quality_image( + *, + image_file_success_state_low_rating: io.BytesIO, + cloud_reco_client: CloudRecoService, + vws_client: VWS, + ) -> None: + """ + If the exact low quality image that was added is queried for, no + results are returned. + """ + image_file = image_file_success_state_low_rating + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) + name = "example_name" + + target_id = vws_client.add_target( + name=name, + width=1, + image=image_file, + active_flag=True, + application_metadata=metadata_encoded, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + matching_targets = cloud_reco_client.query(image=image_file) + assert not matching_targets + + @staticmethod + def test_match_similar( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + ) -> None: + """ + If a similar image to one that was added is queried for, target + data is + shown. + """ + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) + name_matching = "example_name_matching" + name_not_matching = "example_name_not_matching" + + target_id_matching = vws_client.add_target( + name=name_matching, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=metadata_encoded, + ) + + target_id_not_matching = vws_client.add_target( + name=name_not_matching, + width=1, + image=different_high_quality_image, + active_flag=True, + application_metadata=metadata_encoded, + ) + + vws_client.wait_for_target_processed(target_id=target_id_matching) + vws_client.wait_for_target_processed(target_id=target_id_not_matching) + + similar_image_buffer = io.BytesIO() + similar_image_data = copy.copy(x=high_quality_image) + pil_similar_image = Image.open(fp=similar_image_data) + # Re-save means similar but not identical. + pil_similar_image.save(fp=similar_image_buffer, format="JPEG") + + (matching_target,) = cloud_reco_client.query( + image=similar_image_buffer, + max_num_results=5, + ) + + assert matching_target.target_id == target_id_matching + @staticmethod def test_not_base64_encoded_processable( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, not_base64_encoded_processable: str, + cloud_reco_client: CloudRecoService, ) -> None: """ - Vuforia accepts some metadata strings which are not valid base64. - When a target with such a string is matched by a query, Vuforia returns + Vuforia accepts some metadata strings which are not valid + base64. + When a target with such a string is matched by a query, Vuforia + returns an interesting result: * If the metadata string is a length one greater than a multiple of 4, @@ -451,11 +709,8 @@ def test_not_base64_encoded_processable( * If the metadata is three greater than a multiple of 4, the result is padded, then decoded, then encoded. """ - image_content = high_quality_image.getvalue() - name = 'example_name' - target_id = vws_client.add_target( - name=name, + name="example_name", width=1, image=high_quality_image, active_flag=True, @@ -464,111 +719,115 @@ def test_not_base64_encoded_processable( vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - [result] = response.json()['results'] - query_metadata = result['target_data']['application_metadata'] - if len(not_base64_encoded_processable) % 4 == 1: - expected_metadata_original = not_base64_encoded_processable[:-1] - elif len(not_base64_encoded_processable) % 4 == 2: - expected_metadata_original = not_base64_encoded_processable + '==' - else: - assert len(not_base64_encoded_processable) % 4 == 3 - expected_metadata_original = not_base64_encoded_processable + '=' + query_results = cloud_reco_client.query(image=high_quality_image) + (result,) = query_results + assert result.target_data is not None + query_metadata = result.target_data.application_metadata + mod_4_to_expected_metadata_original = { + 1: not_base64_encoded_processable[:-1], + 2: not_base64_encoded_processable + "==", + 3: not_base64_encoded_processable + "=", + } + expected_metadata_original = mod_4_to_expected_metadata_original[ + len(not_base64_encoded_processable) % 4 + ] expected_metadata = base64.b64encode( - base64.b64decode(expected_metadata_original), + s=base64.b64decode(s=expected_metadata_original), ) assert query_metadata == expected_metadata.decode() -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestIncorrectFields: - """ - Tests for incorrect and unexpected fields. - """ + """Tests for incorrect and unexpected fields.""" - def test_missing_image( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_missing_image(vuforia_database: CloudDatabase) -> None: """ - If an image is not given, a ``BAD_REQUEST`` response is returned. + If an image is not given, a ``BAD_REQUEST`` response is + returned. """ - response = query(vuforia_database=vuforia_database, body={}) + response = _query(vuforia_database=vuforia_database, body={}) - assert response.text == 'No image.' + assert response.text == "No image." assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) + @staticmethod def test_extra_fields( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: - """ - If extra fields are given, a ``BAD_REQUEST`` response is returned. + """If extra fields are given, a ``BAD_REQUEST`` response is + returned. + + The Query API documentation says that unknown fields are ignored, + but the real Query API rejects them. """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'extra_field': (None, 1, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "extra_field": (None, 1, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) - assert response.text == 'Unknown parameters in the request.' + assert response.text == "Unknown parameters in the request." assert_vwq_failure( response=response, - content_type='application/json', + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) + @staticmethod def test_missing_image_and_extra_fields( - self, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: - """ - If extra fields are given and no image field is given, a + """If extra fields are given and no image field is given, a ``BAD_REQUEST`` response is returned. The extra field error takes precedence. """ body = { - 'extra_field': (None, 1, 'text/plain'), + "extra_field": (None, 1, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) - assert response.text == 'Unknown parameters in the request.' + assert response.text == "Unknown parameters in the request." assert_vwq_failure( response=response, - content_type='application/json', + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMaxNumResults: - """ - Tests for the ``max_num_results`` parameter. - """ + """Tests for the ``max_num_results`` parameter.""" + @staticmethod def test_default( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, vws_client: VWS, ) -> None: - """ - The default ``max_num_results`` is 1. - """ + """The default ``max_num_results`` is 1.""" image_content = high_quality_image.getvalue() target_id_1 = vws_client.add_target( @@ -589,152 +848,157 @@ def test_default( vws_client.wait_for_target_processed(target_id=target_id_2) body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), + "image": ("image.jpeg", image_content, "image/jpeg"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - assert len(response.json()['results']) == 1 + response_json = json.loads(s=response.text) + assert len(response_json["results"]) == 1 - @pytest.mark.parametrize('num_results', [1, b'1', 50]) + @staticmethod + @pytest.mark.parametrize(argnames="num_results", argvalues=[1, b"1", 50]) def test_valid_accepted( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - num_results: Union[int, bytes], + vuforia_database: CloudDatabase, + num_results: int | bytes, ) -> None: - """ - Numbers between 1 and 50 are valid inputs. + """Numbers between 1 and 50 are valid inputs. - We assert that the response is a success, but not that the maximum - number of results is enforced. + We assert that the response is a success, but not that the + maximum number of results is enforced. This is because uploading 50 images would be very slow. The documentation at - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query # noqa: E501 + https://developer.vuforia.com/library/web-api/vuforia-query-web-api states that this must be between 1 and 10, but in practice, 50 is the maximum. """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, num_results, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, num_results, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - assert response.json()['results'] == [] + response_json = json.loads(s=response.text) + assert response_json["results"] == [] + @staticmethod def test_valid_works( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - A maximum of ``max_num_results`` results are returned. - """ - image_content = high_quality_image.getvalue() - add_and_wait_for_targets( + """A maximum of ``max_num_results`` results are returned.""" + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=3, ) - body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, 2, 'text/plain'), - } - - response = query(vuforia_database=vuforia_database, body=body) + max_num_results = 2 - assert_query_success(response=response) - assert len(response.json()['results']) == 2 + result = cloud_reco_client.query( + image=high_quality_image, + max_num_results=max_num_results, + ) + assert len(result) == max_num_results - @pytest.mark.parametrize('num_results', [-1, 0, 51]) + @staticmethod + @pytest.mark.parametrize(argnames="num_results", argvalues=[-1, 0, 51]) def test_out_of_range( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, num_results: int, + cloud_reco_client: CloudRecoService, ) -> None: - """ - An error is returned if ``max_num_results`` is given as an integer out - of the range (1, 50). + """An error is returned if ``max_num_results`` is given as an + integer + out of the range (1, 50). The documentation at - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. # noqa: E501 + https://developer.vuforia.com/library/web-api/vuforia-query-web-api. states that this must be between 1 and 10, but in practice, 50 is the maximum. """ - image_content = high_quality_image.getvalue() - body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, num_results, 'text/plain'), - } - - response = query(vuforia_database=vuforia_database, body=body) + with pytest.raises( + expected_exception=MaxNumResultsOutOfRangeError, + ) as exc_info: + cloud_reco_client.query( + image=high_quality_image, + max_num_results=num_results, + ) expected_text = ( - f'Integer out of range ({repr(num_results)}) in form data part ' + f"Integer out of range ({num_results}) in form data part " "'max_result'. Accepted range is from 1 to 50 (inclusive)." ) - assert response.text == expected_text + assert exc_info.value.response.text == expected_text assert_vwq_failure( - response=response, - content_type='application/json', + response=exc_info.value.response, + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) + @staticmethod @pytest.mark.parametrize( - 'num_results', - [b'0.1', b'1.1', b'a', b'2147483648'], + argnames="num_results", + argvalues=[b"0.1", b"1.1", b"a", b"2147483648"], ) def test_invalid_type( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, num_results: bytes, ) -> None: - """ - An error is returned if ``max_num_results`` is given as something other - than an integer. + """An error is returned if ``max_num_results`` is given as + something + other than an integer. - Integers greater than 2147483647 are not considered integers because - they are bigger than Java's maximum integer. + Integers greater than 2147483647 are not considered integers + because they are bigger than Java's maximum integer. """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, num_results, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, num_results, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) expected_text = ( f"Invalid value '{num_results.decode()}' in form data part " "'max_result'. " - 'Expecting integer value in range from 1 to 50 (inclusive).' + "Expecting integer value in range from 1 to 50 (inclusive)." ) assert response.text == expected_text assert_vwq_failure( response=response, - content_type='application/json', + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) -def add_and_wait_for_targets( +def _add_and_wait_for_targets( + *, image: io.BytesIO, vws_client: VWS, num_targets: int, ) -> None: - """ - Add targets with the given image. - """ - target_ids = set([]) + """Add targets with the given image.""" + target_ids: Iterable[str] = set() for _ in range(num_targets): target_id = vws_client.add_target( name=uuid.uuid4().hex, @@ -743,159 +1007,275 @@ def add_and_wait_for_targets( active_flag=True, application_metadata=None, ) - target_ids.add(target_id) + target_ids = {*target_ids, target_id} - for target_id in target_ids: - vws_client.wait_for_target_processed(target_id=target_id) + for created_target_id in target_ids: + vws_client.wait_for_target_processed(target_id=created_target_id) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestResultOrder: + """Tests for the order of query results.""" + + @staticmethod + def test_order_is_upload_date_then_target_id( + *, + verify_mock_vuforia: VuforiaBackend, + high_quality_image: io.BytesIO, + vws_client: VWS, + vuforia_database: CloudDatabase, + ) -> None: + """The mock returns matches ordered by upload date. + + The real Query API orders results by match score, which the mock + does not model, so we do not verify this against the real Vuforia + Web Services. + """ + if verify_mock_vuforia == VuforiaBackend.REAL: + pytest.skip(reason="The real Query API orders by match score.") + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, 3, "text/plain"), + } + + response = _query(vuforia_database=vuforia_database, body=body) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + result_target_ids = [ + result["target_id"] for result in response_json["results"] + ] + assert result_target_ids == target_ids + + @staticmethod + def test_max_num_results_keeps_the_first_results( + *, + verify_mock_vuforia: VuforiaBackend, + high_quality_image: io.BytesIO, + vws_client: VWS, + vuforia_database: CloudDatabase, + ) -> None: + """``max_num_results`` truncates a deterministically ordered + list. + + Which matches survive the truncation therefore does not vary + between runs. + """ + if verify_mock_vuforia == VuforiaBackend.REAL: + pytest.skip(reason="The real Query API orders by match score.") + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, 1, "text/plain"), + } + + response = _query(vuforia_database=vuforia_database, body=body) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + (result,) = response_json["results"] + assert result["target_id"] == target_ids[0] + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestIncludeTargetData: - """ - Tests for the ``include_target_data`` parameter. - """ + """Tests for the ``include_target_data`` parameter.""" + @staticmethod def test_default( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: - """ - The default ``include_target_data`` is 'top'. - """ - add_and_wait_for_targets( + """The default ``include_target_data`` is 'top'.""" + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' in result_1 - assert 'target_data' not in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" not in result_2 - @pytest.mark.parametrize('include_target_data', ['top', 'TOP']) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["top", "TOP"], + ) def test_top( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, include_target_data: str, vws_client: VWS, ) -> None: """ - When ``include_target_data`` is set to "top" (case insensitive), only + When ``include_target_data`` is set to "top" (case insensitive), + only the first result includes target data. """ - add_and_wait_for_targets( + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' in result_1 - assert 'target_data' not in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" not in result_2 - @pytest.mark.parametrize('include_target_data', ['none', 'NONE']) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["none", "NONE"], + ) def test_none( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, include_target_data: str, vws_client: VWS, ) -> None: """ - When ``include_target_data`` is set to "none" (case insensitive), no + When ``include_target_data`` is set to "none" (case + insensitive), no results include target data. """ - add_and_wait_for_targets( + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' not in result_1 - assert 'target_data' not in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" not in result_1 + assert "target_data" not in result_2 - @pytest.mark.parametrize('include_target_data', ['all', 'ALL']) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["all", "ALL"], + ) def test_all( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, include_target_data: str, vws_client: VWS, ) -> None: """ - When ``include_target_data`` is set to "all" (case insensitive), all + When ``include_target_data`` is set to "all" (case insensitive), + all results include target data. """ - add_and_wait_for_targets( + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' in result_1 - assert 'target_data' in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" in result_2 - @pytest.mark.parametrize('include_target_data', ['a', True, 0]) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["a", True, 0], + ) def test_invalid_value( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - include_target_data: Any, + vuforia_database: CloudDatabase, + include_target_data: str | bool | int, ) -> None: """ - A ``BAD_REQUEST`` error is given when a string that is not one of + A ``BAD_REQUEST`` error is given when a string that is not one + of 'none', 'top' or 'all' (case insensitive). """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) expected_text = ( - f"Invalid value '{include_target_data}' in form data " - "part 'include_target_data'. " + f"Invalid value '{str(object=include_target_data).lower()}' in " + "form data part 'include_target_data'. " "Expecting one of the (unquoted) string values 'all', 'none' or " "'top'." ) @@ -903,40 +1283,42 @@ def test_invalid_value( assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestAcceptHeader: - """ - Tests for the ``Accept`` header. - """ + """Tests for the ``Accept`` header.""" + @staticmethod @pytest.mark.parametrize( - 'extra_headers', - [ + argnames="extra_headers", + argvalues=[ { - 'Accept': 'application/json', + "Accept": "application/json", }, {}, ], ) def test_valid( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - extra_headers: Dict[str, str], + vuforia_database: CloudDatabase, + extra_headers: dict[str, str], ) -> None: - """ - An ``Accept`` header can be given iff its value is "application/json". + """An ``Accept`` header can be given iff its value is + "application/json". """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -946,42 +1328,55 @@ def test_valid( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, - **extra_headers, - } + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + } | extra_headers - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert_query_success(response=response) - assert response.json()['results'] == [] + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] + @staticmethod def test_invalid( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - A NOT_ACCEPTABLE response is returned if an ``Accept`` header is given + A NOT_ACCEPTABLE response is returned if an ``Accept`` header is + given with a value which is not "application/json". """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -991,48 +1386,59 @@ def test_invalid( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, - 'Accept': 'text/html', + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + "Accept": "text/html", } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.NOT_ACCEPTABLE, content_type=None, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for active versus inactive targets. - """ + """Tests for active versus inactive targets.""" + @staticmethod def test_inactive( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - Images which are not active are not matched. - """ - image_content = high_quality_image.getvalue() + """Images which are not active are not matched.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1042,101 +1448,88 @@ def test_inactive( ) vws_client.wait_for_target_processed(target_id=target_id) + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) - assert_query_success(response=response) - assert response.json()['results'] == [] - - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestBadImage: - """ - Tests for bad images. - """ + """Tests for bad images.""" + @staticmethod def test_corrupted( - self, - vuforia_database: VuforiaDatabase, + *, corrupted_image_file: io.BytesIO, + cloud_reco_client: CloudRecoService, ) -> None: - """ - No error is returned when a corrupted image is given. - """ - corrupted_data = corrupted_image_file.getvalue() - - body = {'image': ('image.jpeg', corrupted_data, 'image/jpeg')} + """No error is returned when a corrupted image is given.""" + results = cloud_reco_client.query(image=corrupted_image_file) + assert results == [] - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] - - def test_not_image( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_not_image(cloud_reco_client: CloudRecoService) -> None: """ - No error is returned when a corrupted image is given. + An ``UNPROCESSABLE_ENTITY`` response is returned when a non- + image is + given. """ - not_image_data = b'not_image_data' + not_image_data = b"not_image_data" - body = {'image': ('image.jpeg', not_image_data, 'image/jpeg')} + with pytest.raises(expected_exception=BadImageError) as exc_info: + cloud_reco_client.query( + image=io.BytesIO(initial_bytes=not_image_data) + ) - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', - ) - assert response.json().keys() == {'transaction_id', 'result_code'} + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMaximumImageFileSize: - """ - Tests for maximum image file sizes. - """ + """Tests for maximum image file sizes.""" - def test_png( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_png(cloud_reco_client: CloudRecoService) -> None: """ According to - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. + https://developer.vuforia.com/library/web-api/vuforia-query-web- + api. the maximum file size is "2MiB for PNG". - Above this limit, a ``ConnectionError`` is raised. + Above this limit, a ``REQUEST_ENTITY_TOO_LARGE`` response is returned. We do not test exactly at this limit, but that may be beneficial in the future. """ max_bytes = 2 * 1024 * 1024 width = height = 835 png_not_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) image_content = png_not_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly smaller than the @@ -1147,22 +1540,19 @@ def test_png( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=png_not_too_large) + assert results == [] width += 1 height += 1 png_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) image_content = png_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly larger than the # maximum file size. @@ -1172,37 +1562,46 @@ def test_png( assert image_content_size > max_bytes assert (image_content_size * 0.95) < max_bytes - with pytest.raises(requests.exceptions.ConnectionError): - query( - vuforia_database=vuforia_database, - body=body, - ) + with pytest.raises( + expected_exception=RequestEntityTooLargeError + ) as exc_info: + cloud_reco_client.query(image=png_too_large) - def test_jpeg( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + content_type="text/html", + cache_control=None, + www_authenticate=None, + connection="Close", + ) + assert response.text == _NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR + + @staticmethod + def test_jpeg(cloud_reco_client: CloudRecoService) -> None: """ According to - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. + https://developer.vuforia.com/library/web-api/vuforia-query-web- + api. the maximum file size is "512 KiB for JPEG". However, this test shows that the maximum size for JPEG is 2 MiB. - Above this limit, a ``ConnectionError`` is raised. + Above this limit, a ``REQUEST_ENTITY_TOO_LARGE`` response is returned. We do not test exactly at this limit, but that may be beneficial in the future. """ max_bytes = 2 * 1024 * 1024 width = height = 1865 jpeg_not_too_large = make_image_file( - file_format='JPEG', - color_space='RGB', + file_format="JPEG", + color_space="RGB", width=width, height=height, ) image_content = jpeg_not_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly smaller than the @@ -1213,21 +1612,18 @@ def test_jpeg( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=jpeg_not_too_large) + assert results == [] width = height = 1866 jpeg_too_large = make_image_file( - file_format='JPEG', - color_space='RGB', + file_format="JPEG", + color_space="RGB", width=width, height=height, ) image_content = jpeg_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly larger than the # maximum file size. @@ -1237,224 +1633,253 @@ def test_jpeg( assert image_content_size > max_bytes assert (image_content_size * 0.95) < max_bytes - with pytest.raises(requests.exceptions.ConnectionError): - query( - vuforia_database=vuforia_database, - body=body, - ) + with pytest.raises( + expected_exception=RequestEntityTooLargeError + ) as exc_info: + cloud_reco_client.query(image=jpeg_too_large) + response = exc_info.value.response -@pytest.mark.usefixtures('verify_mock_vuforia') + assert_vwq_failure( + response=response, + status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + content_type="text/html", + cache_control=None, + www_authenticate=None, + connection="Close", + ) + + assert response.text == _NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMaximumImageDimensions: - """ - Tests for maximum image dimensions. - """ + """Tests for maximum image dimensions.""" - def test_max_height(self, vuforia_database: VuforiaDatabase) -> None: + @staticmethod + def test_max_height( + cloud_reco_client: CloudRecoService, + ) -> None: """ - An error is returned when an image with a height greater than 30000 is + An error is returned when an image with a height greater than + 30000 is given. """ width = 1 max_height = 30000 png_not_too_tall = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=max_height, ) - image_content = png_not_too_tall.getvalue() - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=png_not_too_tall) + assert results == [] png_too_tall = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=max_height + 1, ) - image_content = png_too_tall.getvalue() + with pytest.raises(expected_exception=BadImageError) as exc_info: + cloud_reco_client.query(image=png_too_tall) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text - def test_max_width(self, vuforia_database: VuforiaDatabase) -> None: + @staticmethod + def test_max_width(cloud_reco_client: CloudRecoService) -> None: """ - An error is returned when an image with a width greater than 30000 is + An error is returned when an image with a width greater than + 30000 is given. """ height = 1 max_width = 30000 png_not_too_wide = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=max_width, height=height, ) - image_content = png_not_too_wide.getvalue() - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + result = cloud_reco_client.query(image=png_not_too_wide) + assert result == [] png_too_wide = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=max_width + 1, height=height, ) - image_content = png_too_wide.getvalue() - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + with pytest.raises(expected_exception=BadImageError) as exc_info: + result = cloud_reco_client.query(image=png_too_wide) - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text + @staticmethod + def test_max_pixels(cloud_reco_client: CloudRecoService) -> None: + """No error is returned for an 835 x 835 image.""" + # If we make this 836 then we hit REQUEST_ENTITY_TOO_LARGE errors. + max_height = max_width = 835 + png_not_too_wide = make_image_file( + file_format="PNG", + color_space="RGB", + width=max_width, + height=max_height, + ) + + result = cloud_reco_client.query(image=png_not_too_wide) + assert result == [] + + @staticmethod + def test_small_file_many_pixels( + cloud_reco_client: CloudRecoService, + ) -> None: + """ + No error is returned for an image with a small file size and a + huge number of pixels. + + Unlike ``POST /targets``, the Query API has no limit on the + number of pixels, only on the width and the height. + """ + max_bytes = 2 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + result = cloud_reco_client.query(image=image_file) + assert result == [] -@pytest.mark.usefixtures('verify_mock_vuforia') + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestImageFormats: - """ - Tests for various image formats. - """ + """Tests for various image formats.""" - @pytest.mark.parametrize('file_format', ['png', 'jpeg']) + @staticmethod + @pytest.mark.parametrize(argnames="file_format", argvalues=["png", "jpeg"]) def test_supported( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, file_format: str, + cloud_reco_client: CloudRecoService, ) -> None: - """ - PNG and JPEG formats are supported. - """ + """PNG and JPEG formats are supported.""" image_buffer = io.BytesIO() - pil_image = Image.open(high_quality_image) - pil_image.save(image_buffer, file_format) + pil_image = Image.open(fp=high_quality_image) + pil_image.save(fp=image_buffer, format=file_format) image_content = image_buffer.getvalue() + results = cloud_reco_client.query( + image=io.BytesIO(initial_bytes=image_content) + ) + assert results == [] - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] - + @staticmethod def test_unsupported( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + cloud_reco_client: CloudRecoService, ) -> None: - """ - File formats which are not PNG or JPEG are not supported. - """ - file_format = 'tiff' + """File formats which are not PNG or JPEG are not supported.""" + file_format = "tiff" image_buffer = io.BytesIO() - pil_image = Image.open(high_quality_image) - pil_image.save(image_buffer, file_format) + pil_image = Image.open(fp=high_quality_image) + pil_image.save(fp=image_buffer, format=file_format) image_content = image_buffer.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + with pytest.raises(expected_exception=BadImageError) as exc_info: + cloud_reco_client.query( + image=io.BytesIO(initial_bytes=image_content) + ) - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', - ) - assert response.json().keys() == {'transaction_id', 'result_code'} + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestProcessing: - """ - Tests for targets in the processing state. - """ + """Tests for targets in the processing state.""" - @pytest.mark.parametrize( - 'active_flag', - [True, False], - ) + @staticmethod + @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_processing( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - active_flag: bool, vws_client: VWS, + cloud_reco_client: CloudRecoService, + active_flag: bool, ) -> None: """ - When a target with a matching image is in the processing state it is + When a target with a matching image is in the processing state + it is not matched. - - Sometimes an `INTERNAL_SERVER_ERROR` response is returned. """ - image_content = high_quality_image.getvalue() - target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1462,10 +1887,7 @@ def test_processing( active_flag=active_flag, application_metadata=None, ) - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) - + matching_targets = cloud_reco_client.query(image=high_quality_image) # We assert that after making a query, the target is in the processing # state. # @@ -1482,49 +1904,31 @@ def test_processing( target_details = vws_client.get_target_record(target_id=target_id) assert target_details.status == TargetStatuses.PROCESSING - # Sometimes we get a 500 error, sometimes we do not. - if response.status_code == HTTPStatus.OK: # pragma: no cover - assert response.json()['results'] == [] - assert_query_success(response=response) - return - - # We do not mark this with "pragma: no cover" because we choose to - # implement the mock to have this behavior. - # The response text for a 500 response is not consistent. - # Therefore we only test for consistent features. - assert 'Error 500 Server Error' in response.text - assert 'HTTP ERROR 500' in response.text - assert 'Problem accessing /v1/query' in response.text - - assert_vwq_failure( - response=response, - content_type='text/html; charset=ISO-8859-1', - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) + assert matching_targets == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUpdate: - """ - Tests for updated targets. - """ + """Tests for updated targets.""" + @staticmethod def test_updated_target( - self, + *, high_quality_image: io.BytesIO, different_high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: + """After a target is updated, only the new image can be matched. + + The match result includes the updated name, timestamp and + application metadata. """ - After a target is updated, only the new image can be matched. - The match result includes the updated name, timestamp and application - metadata. - """ - image_content = high_quality_image.getvalue() - metadata = b'example_metadata' - metadata_encoded = base64.b64encode(metadata).decode('ascii') - name = 'example_name' + metadata = b"example_metadata" + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) + name = "example_name" target_id = vws_client.add_target( name=name, width=1, @@ -1533,22 +1937,20 @@ def test_updated_target( application_metadata=metadata_encoded, ) - calendar.timegm(time.gmtime()) + calendar.timegm(tuple=time.gmtime()) vws_client.wait_for_target_processed(target_id=target_id) - new_image_content = different_high_quality_image.getvalue() - - new_name = name + '2' - new_metadata = metadata + b'2' - new_metadata_encoded = base64.b64encode(new_metadata).decode('ascii') + new_name = name + "2" + new_metadata = metadata + b"2" + new_metadata_encoded = base64.b64encode(s=new_metadata).decode( + encoding="ascii" + ) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) - [result] = response.json()['results'] - target_data = result['target_data'] - target_timestamp = target_data['target_timestamp'] - original_target_timestamp = int(target_timestamp) + results = cloud_reco_client.query(image=high_quality_image) + (result,) = results + assert result.target_data is not None + original_target_timestamp = result.target_data.target_timestamp vws_client.update_target( target_id=target_id, @@ -1557,101 +1959,44 @@ def test_updated_target( application_metadata=new_metadata_encoded, ) - approximate_target_updated = calendar.timegm(time.gmtime()) + approximate_target_updated = calendar.timegm(tuple=time.gmtime()) vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', new_image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) + results = cloud_reco_client.query(image=different_high_quality_image) - assert_query_success(response=response) - [result] = response.json()['results'] - assert result.keys() == {'target_id', 'target_data'} - assert result['target_id'] == target_id - target_data = result['target_data'] - assert target_data.keys() == { - 'application_metadata', - 'name', - 'target_timestamp', - } - assert target_data['application_metadata'] == new_metadata_encoded - assert target_data['name'] == new_name - target_timestamp = target_data['target_timestamp'] - assert isinstance(target_timestamp, int) + (result,) = results + assert result.target_data is not None + assert result.target_data.application_metadata == new_metadata_encoded + assert result.target_data.name == new_name + target_timestamp = result.target_data.target_timestamp # In the future we might want to test that # target_timestamp > original_target_timestamp # However, this requires us to set the mock processing time at > 1 # second. assert target_timestamp >= original_target_timestamp - time_difference = abs(approximate_target_updated - target_timestamp) - assert time_difference < 5 + time_difference = abs( + approximate_target_updated - target_timestamp.timestamp(), + ) + max_time_difference = 5 + assert time_difference < max_time_difference - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDeleted: - """ - Tests for matching deleted targets. - """ - - def test_deleted( - self, - high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - vws_client: VWS, - ) -> None: - """ - Within approximately 7 seconds of deleting a target, querying for its - image results in an ``INTERNAL_SERVER_ERROR``. - """ - image_content = high_quality_image.getvalue() - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) + """Tests for matching deleted targets.""" - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - while True: - response = query(vuforia_database=vuforia_database, body=body) - # Sometimes the first response(s) include the old target. - try: - assert_query_success(response=response) - except AssertionError: - # The response text for a 500 response is not consistent. - # Therefore we only test for consistent features. - assert 'Error 500 Server Error' in response.text - assert 'HTTP ERROR 500' in response.text - assert 'Problem accessing /v1/query' in response.text - - assert_vwq_failure( - response=response, - content_type='text/html; charset=ISO-8859-1', - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return - - def test_deleted_and_wait( - self, + @staticmethod + def test_deleted_active( + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - After waiting approximately 7 seconds (we wait more to be safer), a - deleted target is not found when its image is queried for. - """ - image_content = high_quality_image.getvalue() + """Deleted targets are not matched.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1662,66 +2007,37 @@ def test_deleted_and_wait( vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - # In practice, we have seen a delay of up to 30 seconds between - # deleting a target and getting a valid response which has a result - # array without the deleted item. - total_waited = 0 - - # We wait up to 60 seconds to be safe to avoid indefinite waits and - # using up our quota. - max_wait_seconds = 60 - - # We do not want to retry immediately else we risk using our request - # quota. - sleep_seconds = 2 - - server_error_seen = False - - while True: - response = query(vuforia_database=vuforia_database, body=body) - - try: - assert_query_success(response=response) - except AssertionError: - server_error_seen = True - # The response text for a 500 response is not consistent. - # Therefore we only test for consistent features. - assert 'Error 500 Server Error' in response.text - assert 'HTTP ERROR 500' in response.text - assert 'Problem accessing /v1/query' in response.text - time.sleep(sleep_seconds) - total_waited += sleep_seconds - else: - if response.json()['results']: - [result] = response.json()['results'] - assert result['target_id'] == target_id - # We never see the target ID after having seen the server - # error. - assert not server_error_seen - time.sleep(sleep_seconds) - total_waited += sleep_seconds - else: - assert response.json()['results'] == [] - break - - assert total_waited < max_wait_seconds - - # The deletion never takes effect immediately. - assert total_waited - + # There is a race condition here. + # In the real Vuforia, it takes some time for the target deletion + # to be reflected in the query endpoint. + # + # This difference is documented in ``differences-to-vws.rst``. + # + # We retry to allow for this difference. + for attempt in Retrying( + wait=wait_fixed(wait=0.1), + stop=stop_after_delay(max_delay=3), + retry=retry_if_exception_type( + exception_types=(AssertionError,), + ), + reraise=True, + ): + with attempt: + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] + + @staticmethod def test_deleted_inactive( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: """ - No error is returned when querying for an image of recently deleted, + No error is returned when querying for an image of recently + deleted, inactive target. """ - image_content = high_quality_image.getvalue() target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1731,31 +2047,22 @@ def test_deleted_inactive( ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetStatusFailed: - """ - Tests for targets with the status "failed". - """ + """Tests for targets with the status "failed".""" + @staticmethod def test_status_failed( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - Targets with the status "failed" are not found in query results. - """ - image_content = image_file_failed_state.getvalue() + """Targets with the status "failed" are not found in query results.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1765,17 +2072,13 @@ def test_status_failed( ) vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=image_file_failed_state) + assert results == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDateFormats: - """ - Tests for various date formats. + """Tests for various date formats. The date format for the VWS API as per https://library.vuforia.com/articles/Training/Using-the-VWS-API.html must @@ -1784,45 +2087,45 @@ class TestDateFormats: However, for the query endpoint, the documentation does not mention the format. It says: - > The data format must exactly match the Date that is sent in the ‘Date’ + > The data format must exactly match the Date that is sent in the `Date` > header. """ + @staticmethod @pytest.mark.parametrize( - 'datetime_format', - [ - '%a, %b %d %H:%M:%S %Y', - '%a %b %d %H:%M:%S %Y', - '%a, %d %b %Y %H:%M:%S', - '%a %d %b %Y %H:%M:%S', + argnames="datetime_format", + argvalues=[ + "%a, %b %d %H:%M:%S %Y", + "%a %b %d %H:%M:%S %Y", + "%a, %d %b %Y %H:%M:%S", + "%a %d %b %Y %H:%M:%S", ], ) - @pytest.mark.parametrize('include_tz', [True, False]) + @pytest.mark.parametrize(argnames="include_tz", argvalues=[True, False]) def test_date_formats( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, datetime_format: str, include_tz: bool, ) -> None: - """ - Test various date formats which are known to be accepted. + """Test various date formats which are known to be accepted. - We expect that more formats than this will be accepted. - These are the accepted ones we know of at the time of writing. + We expect that more formats than this will be accepted. These + are the accepted ones we know of at the time of writing. """ image_content = high_quality_image.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + body = {"image": ("image.jpeg", image_content, "image/jpeg")} if include_tz: - datetime_format += ' GMT' + datetime_format += " GMT" - gmt = ZoneInfo('GMT') + gmt = ZoneInfo(key="GMT") now = datetime.datetime.now(tz=gmt) - date = now.strftime(datetime_format) - request_path = '/v1/query' - content, content_type_header = encode_multipart_formdata(body) - method = POST + date = now.strftime(format=datetime_format) + request_path = "/v1/query" + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -1831,63 +2134,80 @@ def test_date_formats( secret_key=secret_key, method=method, content=content, - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert_query_success(response=response) - assert response.json()['results'] == [] + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" + @staticmethod def test_inactive_project( - self, - inactive_database: VuforiaDatabase, + *, high_quality_image: io.BytesIO, + inactive_cloud_reco_client: CloudRecoService, ) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - image_content = high_quality_image.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + with pytest.raises( + expected_exception=InactiveProjectError + ) as exc_info: + inactive_cloud_reco_client.query(image=high_quality_image) - response = query(vuforia_database=inactive_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.FORBIDDEN, - content_type='application/json', + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.INACTIVE_PROJECT.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"InactiveProject"' + "}" ) assert response.text == expected_text diff --git a/tests/mock_vws/test_reco_counts_report.py b/tests/mock_vws/test_reco_counts_report.py new file mode 100644 index 000000000..f02ccb2f1 --- /dev/null +++ b/tests/mock_vws/test_reco_counts_report.py @@ -0,0 +1,228 @@ +"""Tests for the mock of the reco counts report endpoint.""" + +import datetime +import json +import time +import uuid +from http import HTTPMethod, HTTPStatus +from string import hexdigits +from zoneinfo import ZoneInfo + +import pytest +import requests +from beartype import beartype +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws._constants import ResultCodes +from mock_vws.database import CloudDatabase + +_VWS_HOST = "https://vws.vuforia.com" +# The number of seconds which the mocks take to generate a report. +# This matches the default processing time of the mocks. +_GENERATION_TIME_SECONDS = 2 + + +@beartype +def _month_offset_from_now(*, months: int) -> str: + """Return a month in ``YYYY-mm`` form, offset from the current + month. + """ + now = datetime.datetime.now(tz=ZoneInfo(key="UTC")) + total_months = now.year * 12 + now.month - 1 + months + year, month_index = divmod(total_months, 12) + return f"{year:04d}-{month_index + 1:02d}" + + +@beartype +def _request_reco_counts_report( + *, + vuforia_database: CloudDatabase, + database_id: str, + month: str | int, +) -> requests.Response: + """Request a reco counts report and return the response. + + The report is requested for the database named by the given ID, and the + request is signed with the given database's server keys. + """ + request_path = f"/imagetargets/databases/{database_id}/reports/recoCounts" + content_type = "application/json" + content = json.dumps(obj={"month": month}).encode(encoding="utf-8") + date = rfc_1123_date() + authorization_string = authorization_header( + access_key=vuforia_database.server_access_key, + secret_key=vuforia_database.server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + return requests.post( + url=_VWS_HOST + request_path, + headers={ + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + }, + data=content, + timeout=30, + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestRecoCountsReport: + """Tests for requesting a reco counts report.""" + + @staticmethod + @pytest.mark.parametrize( + argnames="months_ago", + argvalues=[0, 1], + ids=["current_month", "previous_month"], + ) + def test_reco_counts_report( + *, + vuforia_database: CloudDatabase, + months_ago: int, + ) -> None: + """A report can be requested for the current and previous + month. + """ + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + month=_month_offset_from_now(months=-months_ago), + ) + + assert response.status_code == HTTPStatus.OK + response_json = json.loads(s=response.text) + assert response_json.keys() == { + "result_code", + "transaction_id", + "presigned_url", + } + assert response_json["result_code"] == ResultCodes.SUCCESS.value + transaction_id = response_json["transaction_id"] + assert all(char in hexdigits for char in transaction_id) + assert response_json["presigned_url"].startswith("https://") + + @staticmethod + @pytest.mark.parametrize( + argnames="months_ago", + argvalues=[2, -1], + ids=["too_old", "in_the_future"], + ) + def test_month_out_of_range( + *, + vuforia_database: CloudDatabase, + months_ago: int, + ) -> None: + """Only the current and the previous month can be requested.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + month=_month_offset_from_now(months=-months_ago), + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = json.loads(s=response.text) + assert response_json["result_code"] == ResultCodes.FAIL.value + + @staticmethod + @pytest.mark.parametrize( + argnames="month", + argvalues=["2020", "2020-1", "January", "2020-01-01", 202001], + ids=["year_only", "one_digit", "name", "date", "not_a_string"], + ) + def test_malformed_month( + *, + vuforia_database: CloudDatabase, + month: str | int, + ) -> None: + """The month must be given in the ``YYYY-mm`` form.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + month=month, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = json.loads(s=response.text) + assert response_json["result_code"] == ResultCodes.FAIL.value + + @staticmethod + def test_unknown_database_id(*, vuforia_database: CloudDatabase) -> None: + """The path must name the database which the request's server + keys belong to. + """ + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=uuid.uuid4().hex, + month=_month_offset_from_now(months=0), + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + response_json = json.loads(s=response.text) + assert ( + response_json["result_code"] + == ResultCodes.AUTHENTICATION_FAILURE.value + ) + + @staticmethod + def test_database_name_in_path( + *, + vuforia_database: CloudDatabase, + ) -> None: + """A database is named in the path by its ID, not by its name.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=vuforia_database.database_name, + month=_month_offset_from_now(months=0), + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + response_json = json.loads(s=response.text) + assert ( + response_json["result_code"] + == ResultCodes.AUTHENTICATION_FAILURE.value + ) + + +@pytest.mark.usefixtures("mock_only_vuforia") +class TestDownloadReport: + """Tests for downloading a generated reco counts report. + + Downloads are tested against the mocks only. + Real Vuforia takes between a few seconds and one hour to generate a + report, which is too long to wait for in a test. + """ + + @staticmethod + def test_download_report(*, vuforia_database: CloudDatabase) -> None: + """The report is available from the given URL once it is ready.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + month=_month_offset_from_now(months=0), + ) + presigned_url = json.loads(s=response.text)["presigned_url"] + + not_ready_response = requests.get(url=presigned_url, timeout=30) + assert not_ready_response.status_code == HTTPStatus.NOT_FOUND + + time.sleep(_GENERATION_TIME_SECONDS + 1) + + ready_response = requests.get(url=presigned_url, timeout=30) + assert ready_response.status_code == HTTPStatus.OK + assert ready_response.headers["Content-Type"] == "text/plain" + assert ready_response.text == "target_id,reco_count\r\n" + + @staticmethod + def test_unknown_report() -> None: + """An unknown report is not available.""" + url = f"{_VWS_HOST}/reports/recoCounts/{uuid.uuid4().hex}" + response = requests.get(url=url, timeout=30) + + assert response.status_code == HTTPStatus.NOT_FOUND diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py new file mode 100644 index 000000000..cc1586163 --- /dev/null +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -0,0 +1,1833 @@ +"""Tests for the usage of the mock for ``requests``.""" + +import dataclasses +import datetime +import email.utils +import io +import json +import socket +import zipfile +from http import HTTPStatus +from urllib.parse import urlparse +from zoneinfo import ZoneInfo + +import httpx +import pytest +import requests +from beartype import beartype +from freezegun import freeze_time +from PIL import Image +from vws import VWS, CloudRecoService +from vws.exceptions.vws_exceptions import ( + ProjectSuspendedError, + RequestQuotaReachedError, + TargetQuotaReachedError, + TooManyRequestsError, +) +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws import MissingSchemeError, MockVWS +from mock_vws._constants import ResultCodes +from mock_vws._services_validators.exceptions import ( + TooManyRequestsError as TooManyRequestsValidatorError, +) +from mock_vws._services_validators.request_rate_validators import ( + RequestRateLimiter, +) +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.database_type import DatabaseType +from mock_vws.image_matchers import ExactMatcher, StructuralSimilarityMatcher +from mock_vws.request_rate_limits import ( + DOCUMENTED_REQUEST_RATE_LIMITS, + RateLimitedEndpoint, + RequestRateLimit, + RequestRateLimits, +) +from mock_vws.states import States +from mock_vws.target import ImageTarget, VuMarkTarget +from mock_vws.target_raters import HardcodedTargetTrackingRater +from tests.mock_vws.utils import Endpoint +from tests.mock_vws.utils.assertions import assert_vws_failure +from tests.mock_vws.utils.usage_test_helpers import ( + processing_time_seconds, +) + +_MODEL_TARGET_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_MODEL_TARGET_DATASET_REQUEST = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + + +@beartype +def _not_exact_matcher( + first_image_content: bytes, + second_image_content: bytes, +) -> bool: + """A matcher which returns True if the images are not the same.""" + return first_image_content != second_image_content + + +@beartype +def request_unmocked_address() -> None: + """Make a request, using `requests` to an unmocked, free local address. + + Raises: + requests.exceptions.ConnectionError: This is expected as there is + nothing to connect to. + requests.exceptions.ConnectionError: This request is being made in the + context of a ``responses`` mock which does not mock local + addresses. + """ + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + requests.get(url=f"http://localhost:{port}", timeout=30) + + +@beartype +def request_mocked_address() -> None: + """ + Make a request, using `requests` to an address that is mocked by + `MockVWS`. + """ + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=30, + ) + + +class TestRealHTTP: + """Tests for making requests to mocked and unmocked addresses.""" + + @staticmethod + def test_default() -> None: + """ + By default, the mock stops any requests made with `requests` to + non- + Vuforia addresses, but not to mocked Vuforia endpoints. + """ + with MockVWS(): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + request_unmocked_address() + + # No exception is raised when making a request to a mocked + # endpoint. + request_mocked_address() + + # The mocking stops when the context manager stops. + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + request_unmocked_address() + + @staticmethod + def test_real_http() -> None: + """ + When the `real_http` parameter given to the context manager is + set to + `True`, requests made to unmocked addresses are not stopped. + """ + with ( + MockVWS(real_http=True), + pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ), + ): + request_unmocked_address() + + +class TestResponseDelay: + """Tests for the response delay feature.""" + + @staticmethod + def test_default_no_delay() -> None: + """By default, there is no response delay.""" + with MockVWS(): + # With a very short timeout, the request should still succeed + # because there is no delay + response = requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=0.5, + ) + # We just care that no timeout occurred, not the response content + assert response.status_code is not None + + @staticmethod + def test_delay_causes_timeout() -> None: + """ + When response_delay_seconds is set higher than the client + timeout, + a Timeout exception is raised. + """ + with ( + MockVWS(response_delay_seconds=0.5), + pytest.raises(expected_exception=requests.exceptions.Timeout), + ): + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=0.1, + ) + + @staticmethod + def test_delay_allows_completion() -> None: + """ + When response_delay_seconds is set lower than the client + timeout, + the request completes successfully. + """ + with MockVWS(response_delay_seconds=0.1): + # This should succeed because timeout > delay + response = requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=2.0, + ) + assert response.status_code is not None + + @staticmethod + def test_delay_with_tuple_timeout() -> None: + """ + The response delay works correctly with tuple timeouts + (connect_timeout, read_timeout). + """ + with ( + MockVWS(response_delay_seconds=0.5), + pytest.raises(expected_exception=requests.exceptions.Timeout), + ): + # Tuple timeout: (connect_timeout, read_timeout) + # The read timeout (0.1) is less than the delay (0.5) + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=(5.0, 0.1), + ) + + @staticmethod + def test_custom_sleep_fn_called_on_delay() -> None: + """ + When a custom ``sleep_fn`` is provided, it is called instead of + ``time.sleep`` for the non-timeout delay path. + """ + calls: list[float] = [] + with MockVWS( + response_delay_seconds=5.0, + sleep_fn=calls.append, + ): + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=30, + ) + assert calls == [5.0] + + @staticmethod + def test_custom_sleep_fn_called_on_timeout() -> None: + """ + When a custom ``sleep_fn`` is provided, it is called instead of + ``time.sleep`` for the timeout path. + """ + calls: list[float] = [] + with ( + MockVWS( + response_delay_seconds=5.0, + sleep_fn=calls.append, + ), + pytest.raises(expected_exception=requests.exceptions.Timeout), + ): + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=1.0, + ) + # sleep_fn should have been called with the effective timeout + assert calls == [1.0] + + +class TestProcessingTime: + """Tests for the time taken to process targets in the mock.""" + + # There is a race condition in this test type - if tests start to + # fail, consider increasing the leeway. + LEEWAY = 1.0 + + def test_default(self, image_file_failed_state: io.BytesIO) -> None: + """By default, targets in the mock takes 2 seconds to be processed.""" + database = CloudDatabase() + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + time_taken = processing_time_seconds( + vuforia_database=database, + image=image_file_failed_state, + ) + + expected = 2 + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY + + def test_custom(self, image_file_failed_state: io.BytesIO) -> None: + """It is possible to set a custom processing time.""" + database = CloudDatabase() + seconds = 5 + with MockVWS(processing_time_seconds=seconds) as mock: + mock.add_cloud_database(cloud_database=database) + time_taken = processing_time_seconds( + vuforia_database=database, + image=image_file_failed_state, + ) + + expected = seconds + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY + + +class TestDatabaseName: + """Tests for the database name.""" + + @staticmethod + def test_default() -> None: + """By default, the database has a random name.""" + database_details = CloudDatabase() + other_database_details = CloudDatabase() + assert ( + database_details.database_name + != other_database_details.database_name + ) + + @staticmethod + def test_custom_name() -> None: + """It is possible to set a custom database name.""" + database_details = CloudDatabase(database_name="foo") + assert database_details.database_name == "foo" + + +class TestRequestQuota: + """Tests for request quota exhaustion. + + These tests run only against the mock. Deliberately exhausting the request + quota of the real Vuforia test database would make it unusable for the + rest of the verified-fake test suite. + """ + + @staticmethod + def test_request_quota_available() -> None: + """A database with request quota accepts VWS requests.""" + database = CloudDatabase(request_quota=1) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + targets = client.list_targets() + + assert not targets + + @staticmethod + def test_request_quota_reached() -> None: + """A database with no request quota rejects VWS requests.""" + database = CloudDatabase(request_quota=0) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises( + expected_exception=RequestQuotaReachedError, + ) as exc_info: + client.list_targets() + + assert_vws_failure( + response=exc_info.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code=ResultCodes.REQUEST_QUOTA_REACHED, + ) + + +class TestRequestRateLimit: + """Tests for configurable per-second VWS request limits.""" + + @staticmethod + def test_zero_limit() -> None: + """A zero request rate limit rejects every VWS request.""" + database = CloudDatabase(requests_per_second_limit=0) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises( + expected_exception=TooManyRequestsError, + ) as exc_info: + client.list_targets() + + assert_vws_failure( + response=exc_info.value.response, + status_code=HTTPStatus.TOO_MANY_REQUESTS, + result_code=ResultCodes.TOO_MANY_REQUESTS, + ) + + @staticmethod + def test_rolling_window() -> None: + """Requests are accepted again after the rolling window passes.""" + request_times = iter([10.0, 10.5, 11.0]) + rate_limiter = RequestRateLimiter( + time_function=request_times.__next__, + ) + database = CloudDatabase(requests_per_second_limit=1) + + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.OTHER, + ) + with pytest.raises(expected_exception=TooManyRequestsValidatorError): + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.OTHER, + ) + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.OTHER, + ) + + @staticmethod + def test_limit_applies_to_all_endpoints() -> None: + """The database-wide limit is shared between all endpoints.""" + request_times = iter([10.0, 10.1]) + rate_limiter = RequestRateLimiter( + time_function=request_times.__next__, + ) + database = CloudDatabase(requests_per_second_limit=1) + + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.GET_TARGET, + ) + with pytest.raises(expected_exception=TooManyRequestsValidatorError): + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.LIST_TARGETS, + ) + + +class TestPerEndpointRequestRateLimits: + """Tests for per-endpoint VWS request rate limits.""" + + @staticmethod + def test_endpoints_are_limited_separately() -> None: + """Each endpoint group has its own budget of requests.""" + request_times = iter([10.0, 10.1, 10.2]) + rate_limiter = RequestRateLimiter( + time_function=request_times.__next__, + ) + database = CloudDatabase( + request_rate_limits=RequestRateLimits( + get_target=RequestRateLimit( + max_requests=1, window_seconds=1.0 + ), + get_duplicates=RequestRateLimit( + max_requests=1, window_seconds=1.0 + ), + ), + ) + + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.GET_TARGET, + ) + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.GET_DUPLICATES, + ) + with pytest.raises(expected_exception=TooManyRequestsValidatorError): + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.GET_TARGET, + ) + + @staticmethod + def test_endpoints_without_a_limit_share_the_other_limit() -> None: + """Endpoints with no limit of their own share the ``other`` + limit. + """ + request_times = iter([10.0, 10.1, 10.2]) + rate_limiter = RequestRateLimiter( + time_function=request_times.__next__, + ) + database = CloudDatabase( + request_rate_limits=RequestRateLimits( + other=RequestRateLimit(max_requests=2, window_seconds=1.0), + get_target=RequestRateLimit( + max_requests=1, window_seconds=1.0 + ), + ), + ) + + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.OTHER, + ) + # ``GET /targets`` has no limit of its own, so it shares the ``other`` + # limit. + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.LIST_TARGETS, + ) + with pytest.raises(expected_exception=TooManyRequestsValidatorError): + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.OTHER, + ) + + @staticmethod + def test_windows_longer_than_a_second() -> None: + """A limit may use a window which is longer than one second.""" + request_times = iter([10.0, 40.0, 71.0]) + rate_limiter = RequestRateLimiter( + time_function=request_times.__next__, + ) + database = CloudDatabase( + request_rate_limits=RequestRateLimits( + list_targets=RequestRateLimit( + max_requests=1, + window_seconds=60.0, + ), + ), + ) + + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.LIST_TARGETS, + ) + with pytest.raises(expected_exception=TooManyRequestsValidatorError): + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.LIST_TARGETS, + ) + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.LIST_TARGETS, + ) + + @staticmethod + def test_rejected_requests_do_not_use_other_budgets() -> None: + """A request rejected by one limit does not count towards + another. + """ + request_times = iter([10.0, 10.1, 10.2]) + rate_limiter = RequestRateLimiter( + time_function=request_times.__next__, + ) + database = CloudDatabase( + requests_per_second_limit=5, + request_rate_limits=RequestRateLimits( + list_targets=RequestRateLimit( + max_requests=1, window_seconds=1.0 + ) + ), + ) + + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.LIST_TARGETS, + ) + with pytest.raises(expected_exception=TooManyRequestsValidatorError): + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.LIST_TARGETS, + ) + rate_limiter.validate( + database=database, + endpoint=RateLimitedEndpoint.GET_TARGET, + ) + + @staticmethod + def test_documented_limits() -> None: + """The documented limits are available to use.""" + database = CloudDatabase( + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, + ) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + # ``GET /targets`` is limited to one request per minute. + client.list_targets() + with pytest.raises( + expected_exception=TooManyRequestsError, + ) as exc_info: + client.list_targets() + + # Other endpoints have their own budgets. + client.get_database_summary_report() + + assert_vws_failure( + response=exc_info.value.response, + status_code=HTTPStatus.TOO_MANY_REQUESTS, + result_code=ResultCodes.TOO_MANY_REQUESTS, + ) + + @staticmethod + def test_get_target_and_duplicates_limits( + *, + image_file_failed_state: io.BytesIO, + ) -> None: + """``GET /targets/{target_id}`` and ``GET /duplicates/{target_id}`` + have their own limits. + """ + database = CloudDatabase( + request_rate_limits=RequestRateLimits( + get_target=RequestRateLimit( + max_requests=2, window_seconds=60.0 + ), + get_duplicates=RequestRateLimit( + max_requests=1, + window_seconds=60.0, + ), + ), + ) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS(processing_time_seconds=0) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + client.get_target_record(target_id=target_id) + client.get_duplicate_targets(target_id=target_id) + with pytest.raises(expected_exception=TooManyRequestsError): + client.get_duplicate_targets(target_id=target_id) + client.get_target_record(target_id=target_id) + with pytest.raises(expected_exception=TooManyRequestsError): + client.get_target_record(target_id=target_id) + + +class TestAdditionalResultCodes: + """Tests for configurable, mock-only VWS result codes.""" + + @staticmethod + def test_target_quota_reached( + *, + image_file_failed_state: io.BytesIO, + ) -> None: + """A database at its target quota rejects new targets.""" + database = CloudDatabase(target_quota=0) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises( + expected_exception=TargetQuotaReachedError, + ) as exc_info: + client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc_info.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code=ResultCodes.TARGET_QUOTA_REACHED, + ) + + @staticmethod + def test_project_suspended() -> None: + """A suspended project rejects VWS requests.""" + database = CloudDatabase(state=States.PROJECT_SUSPENDED) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises( + expected_exception=ProjectSuspendedError, + ) as exc: + client.list_targets() + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code=ResultCodes.PROJECT_SUSPENDED, + ) + + @staticmethod + def test_project_has_no_api_access() -> None: + """A project with no API access rejects VWS requests. + + This does not use ``vws-python`` because that library maps this + result code by the ``ProjectHasNoAPIAccess`` spelling, which + Vuforia's result codes table does not use. + """ + database = CloudDatabase(state=States.PROJECT_HAS_NO_API_ACCESS) + request_path = "/targets" + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + date = rfc_1123_date() + auth = authorization_header( + access_key=database.server_access_key, + secret_key=database.server_secret_key, + method="GET", + content=b"", + content_type="", + date=date, + request_path=request_path, + ) + response = requests.get( + url="https://vws.vuforia.com" + request_path, + headers={ + "Authorization": auth, + "Date": date, + }, + timeout=30, + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.json()["result_code"] == "ProjectHasNoApiAccess" + + +class TestCustomBaseURLs: + """Tests for using custom base URLs.""" + + @staticmethod + def test_custom_base_vws_url() -> None: + """It is possible to use a custom base VWS URL.""" + with MockVWS( + base_vws_url="https://vuforia.vws.example.com", + real_http=False, + ): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.get(url="https://vws.vuforia.com/summary", timeout=30) + + requests.get( + url="https://vuforia.vws.example.com/summary", + timeout=30, + ) + requests.post( + url="https://cloudreco.vuforia.com/v1/query", + timeout=30, + ) + + @staticmethod + def test_custom_base_vwq_url() -> None: + """It is possible to use a custom base cloud recognition URL.""" + with MockVWS( + base_vwq_url="https://vuforia.vwq.example.com", + real_http=False, + ): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.post( + url="https://cloudreco.vuforia.com/v1/query", + timeout=30, + ) + + requests.post( + url="https://vuforia.vwq.example.com/v1/query", + timeout=30, + ) + requests.get( + url="https://vws.vuforia.com/summary", + timeout=30, + ) + + @staticmethod + def test_custom_base_vws_url_with_path_prefix() -> None: + """A custom base VWS URL with a path prefix intercepts at the + prefix. + """ + with MockVWS( + base_vws_url="https://vuforia.vws.example.com/prefix", + real_http=False, + ): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.get( + url="https://vuforia.vws.example.com/summary", + timeout=30, + ) + + requests.get( + url="https://vuforia.vws.example.com/prefix/summary", + timeout=30, + ) + + @staticmethod + def test_custom_base_vwq_url_with_path_prefix() -> None: + """A custom base VWQ URL with a path prefix intercepts at the + prefix. + """ + with MockVWS( + base_vwq_url="https://vuforia.vwq.example.com/prefix", + real_http=False, + ): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.post( + url="https://vuforia.vwq.example.com/v1/query", + timeout=30, + ) + + requests.post( + url="https://vuforia.vwq.example.com/prefix/v1/query", + timeout=30, + ) + + @staticmethod + def test_vws_operations_work_with_path_prefix() -> None: + """VWS API operations work correctly with a base URL path + prefix. + """ + database = CloudDatabase() + base_vws_url = "https://vuforia.vws.example.com/prefix" + + with MockVWS(base_vws_url=base_vws_url) as mock: + mock.add_cloud_database(cloud_database=database) + + request_path = "/targets" + date = rfc_1123_date() + auth = authorization_header( + access_key=database.server_access_key, + secret_key=database.server_secret_key, + method="GET", + content=b"", + content_type="", + date=date, + request_path=request_path, + ) + response = requests.get( + url=base_vws_url + request_path, + headers={ + "Authorization": auth, + "Date": date, + }, + timeout=30, + ) + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + assert response_json["result_code"] == "Success" + assert response_json["results"] == [] + + @staticmethod + def test_no_scheme() -> None: + """An error if raised if a URL is given with no scheme.""" + with pytest.raises(expected_exception=MissingSchemeError) as vws_exc: + MockVWS(base_vws_url="vuforia.vws.example.com") + + expected = ( + 'Invalid URL "vuforia.vws.example.com": No scheme supplied. ' + 'Perhaps you meant "https://vuforia.vws.example.com".' + ) + assert str(object=vws_exc.value) == expected + with pytest.raises(expected_exception=MissingSchemeError) as vwq_exc: + MockVWS(base_vwq_url="vuforia.vwq.example.com") + expected = ( + 'Invalid URL "vuforia.vwq.example.com": No scheme supplied. ' + 'Perhaps you meant "https://vuforia.vwq.example.com".' + ) + assert str(object=vwq_exc.value) == expected + + +class TestTargets: + """Tests for target representations.""" + + @staticmethod + def test_to_dict(high_quality_image: io.BytesIO) -> None: + """ + It is possible to dump a target to a dictionary and load it + back. + """ + database = CloudDatabase() + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert len(database.targets) == 1 + target = next(iter(database.targets)) + assert isinstance(target, ImageTarget) + target_dict = target.to_dict() + + # The dictionary is JSON dump-able + assert json.dumps(obj=target_dict) + + new_target = ImageTarget.from_dict(target_dict=target_dict) + assert new_target == target + + @staticmethod + def test_to_dict_deleted(high_quality_image: io.BytesIO) -> None: + """ + It is possible to dump a deleted target to a dictionary and load + it + back. + """ + database = CloudDatabase() + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.delete_target(target_id=target_id) + + assert len(database.targets) == 1 + target = next(iter(database.targets)) + assert isinstance(target, ImageTarget) + target_dict = target.to_dict() + + # The dictionary is JSON dump-able + assert json.dumps(obj=target_dict) + + new_target = ImageTarget.from_dict(target_dict=target_dict) + assert new_target.delete_date == target.delete_date + + @staticmethod + def test_round_trip_non_default_fields( + high_quality_image: io.BytesIO, + ) -> None: + """Every field of a target survives a dictionary round trip. + + The target tracking rater is deliberately not preserved: + ``to_dict`` writes the computed tracking rating and ``from_dict`` + rebuilds the target with a hardcoded rater which gives that + rating. + """ + gmt = ZoneInfo(key="GMT") + target = ImageTarget( + active_flag=False, + application_metadata="example-metadata", + current_month_recos=1, + delete_date=datetime.datetime( + year=2020, month=1, day=4, tzinfo=gmt + ), + image_value=high_quality_image.getvalue(), + last_modified_date=datetime.datetime( + year=2020, month=1, day=3, tzinfo=gmt + ), + name="example", + previous_month_recos=2, + processing_time_seconds=0.5, + reco_rating="example-reco-rating", + target_id="example-target-id", + target_tracking_rater=HardcodedTargetTrackingRater(rating=4), + total_recos=3, + upload_date=datetime.datetime( + year=2020, month=1, day=2, tzinfo=gmt + ), + width=1.5, + ) + # Adding a field to ``ImageTarget`` must mean adding it to this + # test, and therefore to the round trip. + expected_field_names = { + "active_flag", + "application_metadata", + "current_month_recos", + "delete_date", + "image_value", + "last_modified_date", + "name", + "previous_month_recos", + "processing_time_seconds", + "reco_rating", + "target_id", + "target_tracking_rater", + "total_recos", + "upload_date", + "width", + } + field_names = { + field.name + for field in dataclasses.fields(class_or_instance=ImageTarget) + } + assert field_names == expected_field_names + + target_dict = target.to_dict() + # The dictionary is JSON dump-able + assert json.dumps(obj=target_dict) + + new_target = ImageTarget.from_dict(target_dict=target_dict) + assert new_target == target + assert new_target.tracking_rating == target.tracking_rating + + @staticmethod + def test_vumark_target_to_dict() -> None: + """It is possible to dump a VuMark target to a dictionary and + load it back. + """ + vumark_target = VuMarkTarget( + name="example-vumark", + processing_time_seconds=5.0, + ) + target_dict = vumark_target.to_dict() + + assert json.dumps(obj=target_dict) + + new_target = VuMarkTarget.from_dict(target_dict=target_dict) + assert new_target == vumark_target + + +class TestDatabaseToDict: + """Tests for dumping a database to a dictionary.""" + + @staticmethod + def test_to_dict(high_quality_image: io.BytesIO) -> None: + """ + It is possible to dump a database to a dictionary and load it + back. + """ + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + # We test a database with a target added. + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + database_dict = database.to_dict() + # The dictionary is JSON dump-able + assert json.dumps(obj=database_dict) + + new_database = CloudDatabase.from_dict(database_dict=database_dict) + assert new_database == database + + @staticmethod + def test_custom_request_quota() -> None: + """The request quota survives a dictionary round trip.""" + database = CloudDatabase(request_quota=0) + + database_dict = database.to_dict() + new_database = CloudDatabase.from_dict(database_dict=database_dict) + + assert new_database.request_quota == 0 + + @staticmethod + def test_custom_target_quota() -> None: + """The target quota survives a dictionary round trip.""" + database = CloudDatabase(target_quota=0) + + database_dict = database.to_dict() + new_database = CloudDatabase.from_dict(database_dict=database_dict) + + assert new_database.target_quota == 0 + + @staticmethod + def test_custom_requests_per_second_limit() -> None: + """The per-second request limit survives a dictionary round + trip. + """ + requests_per_second_limit = 12 + database = CloudDatabase( + requests_per_second_limit=requests_per_second_limit + ) + + database_dict = database.to_dict() + new_database = CloudDatabase.from_dict(database_dict=database_dict) + + assert ( + new_database.requests_per_second_limit == requests_per_second_limit + ) + + @staticmethod + def test_custom_request_rate_limits() -> None: + """Per-endpoint request rate limits survive a dictionary round + trip. + """ + database = CloudDatabase( + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, + ) + + database_dict = database.to_dict() + assert json.dumps(obj=database_dict) + new_database = CloudDatabase.from_dict(database_dict=database_dict) + + assert ( + new_database.request_rate_limits == DOCUMENTED_REQUEST_RATE_LIMITS + ) + + @staticmethod + def test_round_trip_non_default_fields( + high_quality_image: io.BytesIO, + ) -> None: + """Every field of a database survives a dictionary round trip.""" + gmt = ZoneInfo(key="GMT") + target = ImageTarget( + active_flag=True, + application_metadata=None, + image_value=high_quality_image.getvalue(), + last_modified_date=datetime.datetime( + year=2020, month=1, day=3, tzinfo=gmt + ), + name="example", + processing_time_seconds=0.5, + target_tracking_rater=HardcodedTargetTrackingRater(rating=4), + upload_date=datetime.datetime( + year=2020, month=1, day=2, tzinfo=gmt + ), + width=1.5, + ) + database = CloudDatabase( + client_access_key="example-client-access-key", + client_secret_key="example-client-secret-key", + current_month_recos=1, + database_id="example-database-id", + database_name="example-database-name", + # ``CLOUD_RECO`` is the only database type, so it is not + # possible to use a non-default value here. + database_type=DatabaseType.CLOUD_RECO, + previous_month_recos=2, + reco_threshold=3, + request_quota=4, + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, + requests_per_second_limit=5, + server_access_key="example-server-access-key", + server_secret_key="example-server-secret-key", + state=States.PROJECT_SUSPENDED, + target_quota=6, + targets={target}, + total_recos=7, + ) + # Adding a field to ``CloudDatabase`` must mean adding it to this + # test, and therefore to the round trip. + expected_field_names = { + "client_access_key", + "client_secret_key", + "current_month_recos", + "database_id", + "database_name", + "database_type", + "previous_month_recos", + "reco_threshold", + "request_quota", + "request_rate_limits", + "requests_per_second_limit", + "server_access_key", + "server_secret_key", + "state", + "target_quota", + "targets", + "total_recos", + } + field_names = { + field.name + for field in dataclasses.fields(class_or_instance=CloudDatabase) + } + assert field_names == expected_field_names + + database_dict = database.to_dict() + # The dictionary is JSON dump-able + assert json.dumps(obj=database_dict) + + new_database = CloudDatabase.from_dict(database_dict=database_dict) + assert new_database == database + + @staticmethod + def test_vumark_database_to_dict() -> None: + """It is possible to dump a VuMark database to a dictionary and + load it back. + """ + vumark_target = VuMarkTarget( + name="example-vumark", + processing_time_seconds=3.0, + ) + database = VuMarkDatabase( + vumark_targets={vumark_target}, + ) + + database_dict = database.to_dict() + assert json.dumps(obj=database_dict) + + new_database = VuMarkDatabase.from_dict(database_dict=database_dict) + assert new_database == database + + +class TestDateHeader: + """Tests for the date header in responses from mock routes.""" + + @staticmethod + def test_date_changes() -> None: + """ + The date that the response is sent is in the response Date + header. + """ + new_year = 2012 + new_time = datetime.datetime( + year=new_year, + month=1, + day=1, + tzinfo=datetime.UTC, + ) + with MockVWS(), freeze_time(time_to_freeze=new_time): + response = requests.get( + url="https://vws.vuforia.com/summary", + timeout=30, + ) + + date_response = response.headers["Date"] + date_from_response = email.utils.parsedate(data=date_response) + assert date_from_response is not None + year = date_from_response[0] + assert year == new_year + + +class TestAddDatabase: + """Tests for adding databases to the mock.""" + + @staticmethod + def test_duplicate_keys() -> None: + """ + It is not possible to have multiple databases with matching + keys. + """ + database = CloudDatabase( + server_access_key="1", + server_secret_key="2", + client_access_key="3", + client_secret_key="4", + database_name="5", + ) + + bad_server_access_key_db = CloudDatabase(server_access_key="1") + bad_server_secret_key_db = CloudDatabase(server_secret_key="2") + bad_client_access_key_db = CloudDatabase(client_access_key="3") + bad_client_secret_key_db = CloudDatabase(client_secret_key="4") + bad_database_name_db = CloudDatabase(database_name="5") + + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' + ) + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + client_access_key_conflict_error = ( + "All client access keys must be unique. " + 'There is already a database with the client access key "3".' + ) + client_secret_key_conflict_error = ( + "All client secret keys must be unique. " + 'There is already a database with the client secret key "4".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "5".' + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_client_access_key_db, client_access_key_conflict_error), + (bad_client_secret_key_db, client_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + with pytest.raises( + expected_exception=ValueError, + match=expected_message + "$", + ): + mock.add_cloud_database(cloud_database=bad_database) + + @staticmethod + def test_duplicate_vumark_keys() -> None: + """ + It is not possible to have multiple databases with matching + keys, including VuMark databases. + """ + database = VuMarkDatabase( + server_access_key="1", + server_secret_key="2", + database_name="3", + ) + + bad_server_access_key_db = VuMarkDatabase(server_access_key="1") + bad_server_secret_key_db = VuMarkDatabase(server_secret_key="2") + bad_database_name_db = VuMarkDatabase(database_name="3") + + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' + ) + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "3".' + ) + + with MockVWS() as mock: + mock.add_vumark_database(vumark_database=database) + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + with pytest.raises( + expected_exception=ValueError, + match=expected_message + "$", + ): + mock.add_vumark_database(vumark_database=bad_database) + + +class TestQueryImageMatchers: + """Tests for query image matchers.""" + + @staticmethod + def test_exact_match(high_quality_image: io.BytesIO) -> None: + """The exact matcher matches only exactly the same images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(query_match_checker=ExactMatcher()) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert len(same_image_result) == 1 + different_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert not different_image_result + + @staticmethod + def test_custom_matcher(high_quality_image: io.BytesIO) -> None: + """It is possible to use a custom matcher.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(query_match_checker=_not_exact_matcher) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert not same_image_result + different_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert len(different_image_result) == 1 + + @staticmethod + def test_structural_similarity_matcher( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + ) -> None: + """The structural similarity matcher matches similar images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS( + query_match_checker=StructuralSimilarityMatcher(), + ) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert len(same_image_result) == 1 + similar_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert len(similar_image_result) == 1 + + different_image_result = cloud_reco_client.query( + image=different_high_quality_image, + ) + assert not different_image_result + + +class TestDuplicatesImageMatchers: + """Tests for duplicates image matchers.""" + + @staticmethod + def test_exact_match(high_quality_image: io.BytesIO) -> None: + """The exact matcher matches only exactly the same images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(duplicate_match_checker=ExactMatcher()) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example_0", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + not_duplicate_target_id = vws_client.add_target( + name="example_2", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + vws_client.wait_for_target_processed( + target_id=not_duplicate_target_id, + ) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + @staticmethod + def test_custom_matcher(high_quality_image: io.BytesIO) -> None: + """It is possible to use a custom matcher.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(duplicate_match_checker=_not_exact_matcher) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example_0", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + not_duplicate_target_id = vws_client.add_target( + name="example_2", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + vws_client.wait_for_target_processed( + target_id=not_duplicate_target_id, + ) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [not_duplicate_target_id] + + @staticmethod + def test_structural_similarity_matcher( + high_quality_image: io.BytesIO, + ) -> None: + """The structural similarity matcher matches similar images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS( + duplicate_match_checker=StructuralSimilarityMatcher(), + ) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + +# This is in the wrong file really as it hits both the in memory mock and the +# Flask app. +@pytest.mark.usefixtures("mock_only_vuforia") +class TestDataTypes: + """Tests for sending various data types.""" + + @staticmethod + def test_text(endpoint: Endpoint) -> None: + """It is possible to send strings to VWS endpoints.""" + netloc = urlparse(url=endpoint.base_url).netloc + + if netloc == "cloudreco.vuforia.com": + pytest.skip() + + assert isinstance(endpoint.data, bytes) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=endpoint.headers, + data=endpoint.data.decode(encoding="utf-8"), + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + response = new_endpoint.send() + assert response.status_code == endpoint.successful_headers_status_code + + +class TestHttpxAlsoIntercepted: + """Tests that MockVWS also intercepts httpx requests.""" + + @staticmethod + def test_httpx_vuforia_endpoint_intercepted() -> None: + """``MockVWS`` intercepts ``httpx`` requests to Vuforia + endpoints. + """ + with MockVWS(): + response = httpx.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + timeout=30, + ) + assert response.status_code is not None + + @staticmethod + def test_httpx_unmocked_address_blocked() -> None: + """``MockVWS`` blocks ``httpx`` requests to non-Vuforia + addresses. + """ + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + with MockVWS(), pytest.raises(expected_exception=httpx.ConnectError): + httpx.get(url=f"http://localhost:{port}", timeout=30) + + @staticmethod + def test_httpx_real_http() -> None: + """When ``real_http=True``, ``httpx`` requests to non-Vuforia + addresses are not blocked. + """ + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + with ( + MockVWS(real_http=True), + pytest.raises(expected_exception=httpx.ConnectError), + ): + httpx.get(url=f"http://localhost:{port}", timeout=30) + + +class TestModelTargetWebAPI: + """Tests for the Model Target Web API.""" + + @staticmethod + def test_standard_dataset_workflow() -> None: + """A standard Model Target dataset can be created and + downloaded. + """ + with MockVWS(processing_time_seconds=0): + token_response = requests.post( + url="https://vws.vuforia.com/oauth2/token", + auth=("client-id", "client-secret"), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + token = token_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + create_response = requests.post( + url="https://vws.vuforia.com/modeltargets/datasets", + headers=headers, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + + status_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + dataset_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/dataset" + ), + headers=headers, + timeout=30, + ) + + assert token_response.status_code == HTTPStatus.OK + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.json()["status"] == "done" + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=dataset_response.content), + ) as dataset_zip: + assert dataset_zip.namelist() == ["dataset.json"] + + @staticmethod + def test_advanced_dataset_workflow() -> None: + """An advanced Model Target dataset can be created.""" + with MockVWS(processing_time_seconds=0): + response = requests.post( + url="https://vws.vuforia.com/modeltargets/advancedDatasets", + headers={"Authorization": _MODEL_TARGET_AUTHORIZATION}, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = response.json()["uuid"] + status_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/" + f"advancedDatasets/{dataset_uuid}/status" + ), + headers={"Authorization": _MODEL_TARGET_AUTHORIZATION}, + timeout=30, + ) + + assert response.status_code == HTTPStatus.CREATED + assert status_response.json()["uuid"] == dataset_uuid + + @staticmethod + def test_dataset_download_is_reproducible() -> None: + """Downloading the same dataset produces identical bytes.""" + headers = {"Authorization": _MODEL_TARGET_AUTHORIZATION} + with MockVWS(processing_time_seconds=0): + with freeze_time(time_to_freeze="2026-01-01"): + create_response = requests.post( + url="https://vws.vuforia.com/modeltargets/datasets", + headers=headers, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + dataset_url = ( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/dataset" + ) + with freeze_time(time_to_freeze="2026-01-02"): + first_response = requests.get( + url=dataset_url, + headers=headers, + timeout=30, + ) + with freeze_time(time_to_freeze="2027-01-02"): + second_response = requests.get( + url=dataset_url, + headers=headers, + timeout=30, + ) + + assert first_response.status_code == HTTPStatus.OK + assert second_response.status_code == HTTPStatus.OK + assert first_response.content == second_response.content + + @staticmethod + def test_bearer_token_required() -> None: + """Model Target dataset routes require a bearer token.""" + with MockVWS(): + response = requests.post( + url="https://vws.vuforia.com/modeltargets/datasets", + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.json()["error"] == { + "code": "401", + "message": "no Bearer token", + "target": "jwt", + } diff --git a/tests/mock_vws/test_respx_mock_usage.py b/tests/mock_vws/test_respx_mock_usage.py new file mode 100644 index 000000000..3b3c5d5a9 --- /dev/null +++ b/tests/mock_vws/test_respx_mock_usage.py @@ -0,0 +1,210 @@ +"""Tests for ``MockVWS`` intercepting ``httpx`` via synchronous ``vws`` +clients. +""" + +import io +import uuid +from http import HTTPStatus + +import httpx +import pytest +from vws import VWS, CloudRecoService, VuMarkService +from vws.exceptions.vws_exceptions import UnknownTargetError +from vws.reports import TargetStatuses +from vws.transports import HTTPXTransport +from vws.vumark_accept import VuMarkAccept + +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.image_matchers import ExactMatcher +from mock_vws.target import VuMarkTarget + +_MODEL_TARGET_AUTHORIZATION = "Bearer eyJhbGciOiJtb2NrIn0.e30.c2lnbmF0dXJl" +_MODEL_TARGET_DATASET_REQUEST = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + + +class TestVWS: + """Synchronous ``vws-python`` client usage through the mock via + ``httpx``. + """ + + @staticmethod + def test_response_delay_causes_httpx_timeout() -> None: + """``httpx`` timeouts are surfaced through ``VWS``.""" + database = CloudDatabase() + calls: list[float] = [] + + with MockVWS( + response_delay_seconds=5.0, + sleep_fn=calls.append, + processing_time_seconds=0, + ) as mock: + mock.add_cloud_database(cloud_database=database) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=0.1, + transport=HTTPXTransport(), + ) + with pytest.raises(expected_exception=httpx.ReadTimeout): + client.get_database_summary_report() + + assert calls == [0.1] + + @staticmethod + def test_custom_base_vws_url_with_path_prefix() -> None: + """``VWS`` works with a custom VWS base URL path prefix.""" + database = CloudDatabase() + base_vws_url = "https://vuforia.vws.example.com/prefix" + + with MockVWS(base_vws_url=base_vws_url) as mock: + mock.add_cloud_database(cloud_database=database) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + transport=HTTPXTransport(), + ) + report = client.get_database_summary_report() + database_name = report.name + + assert database_name == database.database_name + + @staticmethod + def test_add_get_and_delete_target( + image_file_success_state_low_rating: io.BytesIO, + ) -> None: + """A target life cycle works through ``VWS``.""" + database = CloudDatabase() + target_name = "async-target" + + with MockVWS(processing_time_seconds=0) as mock: + mock.add_cloud_database(cloud_database=database) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=HTTPXTransport(), + ) + target_id = client.add_target( + name=target_name, + width=1, + image=image_file_success_state_low_rating, + application_metadata=None, + active_flag=True, + ) + client.wait_for_target_processed(target_id=target_id) + target_record = client.get_target_record(target_id=target_id) + assert target_record.status == TargetStatuses.SUCCESS + assert target_record.target_record.name == target_name + + client.delete_target(target_id=target_id) + + with pytest.raises(expected_exception=UnknownTargetError): + client.get_target_record(target_id=target_id) + + +class TestCloudRecoService: + """Synchronous cloud query usage through the mock via ``httpx``.""" + + @staticmethod + def test_query_returns_match(high_quality_image: io.BytesIO) -> None: + """``CloudRecoService`` returns a match via the mock.""" + database = CloudDatabase() + + with MockVWS( + processing_time_seconds=0, + query_match_checker=ExactMatcher(), + ) as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=HTTPXTransport(), + ) + query_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + transport=HTTPXTransport(), + ) + target_id = vws_client.add_target( + name="query-target", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + results = query_client.query(image=high_quality_image) + assert [result.target_id for result in results] == [target_id] + + +class TestVuMarkService: + """Synchronous VuMark generation usage through the mock via + ``httpx``. + """ + + @staticmethod + def test_generate_vumark_instance_returns_png_bytes() -> None: + """``VuMarkService`` returns VuMark image bytes.""" + vumark_target = VuMarkTarget(name="test-target") + database = VuMarkDatabase(vumark_targets={vumark_target}) + + with MockVWS() as mock: + mock.add_vumark_database(vumark_database=database) + client = VuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=HTTPXTransport(), + ) + response_content = client.generate_vumark_instance( + target_id=vumark_target.target_id, + instance_id=uuid.uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert response_content.startswith(b"\x89PNG") + + +class TestModelTargetWebAPI: + """Model Target Web API usage through the mock via ``httpx``.""" + + @staticmethod + def test_standard_dataset_status() -> None: + """``httpx`` requests can use Model Target Web API routes.""" + with MockVWS(processing_time_seconds=0): + create_response = httpx.post( + url="https://vws.vuforia.com/modeltargets/datasets", + headers={"Authorization": _MODEL_TARGET_AUTHORIZATION}, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + status_response = httpx.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/status" + ), + headers={"Authorization": _MODEL_TARGET_AUTHORIZATION}, + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.json()["status"] == "done" diff --git a/tests/mock_vws/test_target_list.py b/tests/mock_vws/test_target_list.py index 7389244ec..47f7935e7 100644 --- a/tests/mock_vws/test_target_list.py +++ b/tests/mock_vws/test_target_list.py @@ -1,52 +1,72 @@ -""" -Tests for the mock of the target list endpoint. -""" +"""Tests for the mock of the target list endpoint.""" + +import io +import uuid import pytest from vws import VWS +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend + -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetList: - """ - Tests for the mock of the target list endpoint at `/targets`. - """ + """Tests for the mock of the target list endpoint at `/targets`.""" + @staticmethod def test_includes_targets( - self, + *, vws_client: VWS, - target_id: str, + unprocessed_target_id: str, ) -> None: - """ - Targets in the database are returned in the list. - """ - assert vws_client.list_targets() == [target_id] + """Targets in the database are returned in the list.""" + assert vws_client.list_targets() == [unprocessed_target_id] + @staticmethod def test_deleted( - self, + *, vws_client: VWS, target_id: str, ) -> None: - """ - Deleted targets are not returned in the list. - """ - vws_client.wait_for_target_processed(target_id=target_id) + """Deleted targets are not returned in the list.""" vws_client.delete_target(target_id=target_id) - assert vws_client.list_targets() == [] + assert not vws_client.list_targets() + @staticmethod + def test_order_is_upload_date_then_target_id( + *, + verify_mock_vuforia: VuforiaBackend, + high_quality_image: io.BytesIO, + vws_client: VWS, + ) -> None: + """The mock returns targets ordered by upload date. -@pytest.mark.usefixtures('verify_mock_vuforia') + The real Vuforia Web Services do not document an order, so we do + not verify this against them. + """ + if verify_mock_vuforia == VuforiaBackend.REAL: + pytest.skip(reason="The real Vuforia does not document an order.") + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + assert vws_client.list_targets() == target_ids + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: - """ - The project's active state does not affect the target list. - """ + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect the target list.""" # No exception is raised. inactive_vws_client.list_targets() diff --git a/tests/mock_vws/test_target_raters.py b/tests/mock_vws/test_target_raters.py new file mode 100644 index 000000000..27dbc8cb4 --- /dev/null +++ b/tests/mock_vws/test_target_raters.py @@ -0,0 +1,76 @@ +"""Tests for target quality raters.""" + +import io + +import pytest + +from mock_vws.target_raters import ( + BrisqueTargetTrackingRater, + HardcodedTargetTrackingRater, + RandomTargetTrackingRater, +) + + +def test_random_target_tracking_rater() -> None: + """ + Test that the random target tracking rater returns a random + number. + """ + rater = RandomTargetTrackingRater() + image_content = b"content" + # We do not test that the number is truly random, but we think that if we + # try this a number of times, it is highly likely that the numbers will not + # all be the same. + ratings = [rater(image_content=image_content) for _ in range(50)] + sorted_ratings = sorted(ratings) + lowest_rating = sorted_ratings[0] + highest_rating = sorted_ratings[-1] + minimum_rating = 0 + maximum_rating = 5 + assert lowest_rating >= minimum_rating + assert highest_rating <= maximum_rating + assert lowest_rating != highest_rating + + +@pytest.mark.parametrize(argnames="rating", argvalues=range(-10, 10)) +def test_hardcoded_target_tracking_rater(rating: int) -> None: + """ + Test that the hardcoded target tracking rater returns the hardcoded + number. + """ + rater = HardcodedTargetTrackingRater(rating=rating) + image_content = b"content" + ratings = [rater(image_content=image_content) for _ in range(50)] + assert all(given_rating == rating for given_rating in ratings) + + +class TestBrisqueTargetTrackingRater: + """Tests for the BRISQUE target tracking rater.""" + + @staticmethod + def test_low_quality_image( + image_file_success_state_low_rating: io.BytesIO, + ) -> None: + """Test that a low quality image returns a low rating.""" + rater = BrisqueTargetTrackingRater() + image_content = image_file_success_state_low_rating.getvalue() + rating = rater(image_content=image_content) + assert rating == 0 + + @staticmethod + def test_high_quality_image(high_quality_image: io.BytesIO) -> None: + """Test that a high quality image returns a high rating.""" + rater = BrisqueTargetTrackingRater() + image_content = high_quality_image.getvalue() + rating = rater(image_content=image_content) + assert rating > 1 + + @staticmethod + def test_different_high_quality_image( + different_high_quality_image: io.BytesIO, + ) -> None: + """Test that a high quality image returns a high rating.""" + rater = BrisqueTargetTrackingRater() + image_content = different_high_quality_image.getvalue() + rating = rater(image_content=image_content) + assert rating > 1 diff --git a/tests/mock_vws/test_target_summary.py b/tests/mock_vws/test_target_summary.py index d8bf09b24..6f1b7b0d7 100644 --- a/tests/mock_vws/test_target_summary.py +++ b/tests/mock_vws/test_target_summary.py @@ -1,40 +1,34 @@ -""" -Tests for the mock of the target summary endpoint. -""" +"""Tests for the mock of the target summary endpoint.""" import datetime import io import uuid +from zoneinfo import ZoneInfo import pytest -from _pytest.fixtures import SubRequest -from backports.zoneinfo import ZoneInfo from vws import VWS, CloudRecoService -from vws.exceptions import UnknownTarget +from vws.exceptions.vws_exceptions import UnknownTargetError from vws.reports import TargetStatuses -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetSummary: - """ - Tests for the target summary endpoint. - """ + """Tests for the target summary endpoint.""" - @pytest.mark.parametrize('active_flag', [True, False]) + @staticmethod + @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_target_summary( - self, + *, vws_client: VWS, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, image_file_failed_state: io.BytesIO, active_flag: bool, ) -> None: - """ - A target summary is returned. - """ + """A target summary is returned.""" name = uuid.uuid4().hex - gmt = ZoneInfo('GMT') + gmt = ZoneInfo(key="GMT") date_before_add_target = datetime.datetime.now(tz=gmt).date() target_id = vws_client.add_target( @@ -56,10 +50,10 @@ def test_target_summary( # In case the date changes while adding a target # we allow the date before and after adding the target. - assert report.upload_date in ( + assert report.upload_date in { date_before_add_target, date_after_add_target, - ) + } # While processing the tracking rating is -1. assert report.tracking_rating == -1 @@ -67,23 +61,24 @@ def test_target_summary( assert report.current_month_recos == 0 assert report.previous_month_recos == 0 + @staticmethod @pytest.mark.parametrize( - ['image_fixture_name', 'expected_status'], - [ - ('high_quality_image', TargetStatuses.SUCCESS), - ('image_file_failed_state', TargetStatuses.FAILED), + argnames=("image_fixture_name", "expected_status"), + argvalues=[ + ("high_quality_image", TargetStatuses.SUCCESS), + ("image_file_failed_state", TargetStatuses.FAILED), ], ) def test_after_processing( - self, + *, vws_client: VWS, - request: SubRequest, + request: pytest.FixtureRequest, image_fixture_name: str, expected_status: TargetStatuses, ) -> None: - """ - After processing is completed, the tracking rating is in the range of - 0 to 5. + """After processing is completed, the tracking rating is in the + range + of 0 to 5. The documentation says: @@ -95,10 +90,10 @@ def test_after_processing( It also shows that ``reco_rating`` is not provided even when the status is success. """ - image_file = request.getfixturevalue(image_fixture_name) + image_file = request.getfixturevalue(argname=image_fixture_name) target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file, active_flag=True, @@ -121,23 +116,20 @@ def test_after_processing( assert report.previous_month_recos == 0 -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestRecognitionCounts: - """ - Tests for the recognition counts in the summary. - """ + """Tests for the recognition counts in the summary.""" + @staticmethod def test_recognition( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, ) -> None: - """ - The recognition counts stay at 0 even after recognitions. - """ + """The recognition counts stay at 0 even after recognitions.""" target_id = vws_client.add_target( - name='example', + name="example", width=1, image=high_quality_image, active_flag=True, @@ -147,7 +139,7 @@ def test_recognition( vws_client.wait_for_target_processed(target_id=target_id) results = cloud_reco_client.query(image=high_quality_image) - [result] = results + (result,) = results assert result.target_id == target_id report = vws_client.get_target_summary_report(target_id=target_id) @@ -157,20 +149,14 @@ def test_recognition( assert report.previous_month_recos == 0 -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: - """ - The project's active state does not affect getting a target. - """ - with pytest.raises(UnknownTarget): + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect getting a target.""" + with pytest.raises(expected_exception=UnknownTargetError): inactive_vws_client.get_target_summary_report( target_id=uuid.uuid4().hex, ) diff --git a/tests/mock_vws/test_target_validators.py b/tests/mock_vws/test_target_validators.py new file mode 100644 index 000000000..0fc74601c --- /dev/null +++ b/tests/mock_vws/test_target_validators.py @@ -0,0 +1,86 @@ +"""Tests for target ID validators.""" + +from collections.abc import Iterable, Mapping +from functools import partial + +import pytest + +from mock_vws._services_validators import target_validators +from mock_vws._services_validators.target_validators import ( + validate_target_id_exists, +) +from mock_vws.database import CloudDatabase +from mock_vws.target import ImageTarget +from mock_vws.target_raters import HardcodedTargetTrackingRater +from tests.mock_vws.utils import make_image_file + + +def _database_with_target(*, target_id: str) -> CloudDatabase: + """Create a database containing one target with the given ID.""" + target = ImageTarget( + active_flag=True, + application_metadata=None, + image_value=make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ).getvalue(), + name="example", + processing_time_seconds=0, + target_id=target_id, + target_tracking_rater=HardcodedTargetTrackingRater(rating=5), + width=1, + ) + return CloudDatabase(targets={target}) + + +def _always_match_database( + *, + database: CloudDatabase, + request_headers: Mapping[str, str], + request_body: bytes | None, + request_method: str, + request_path: str, + databases: Iterable[CloudDatabase], +) -> CloudDatabase: + """Return the given database regardless of request details.""" + del request_headers + del request_body + del request_method + del request_path + del databases + return database + + +@pytest.mark.parametrize( + argnames=("request_path", "target_id"), + argvalues=[ + ("/targets/instances", "instances"), + ("/targets/target123/instances", "target123"), + ], +) +def test_validate_target_id_exists_uses_correct_path_segment( + *, + request_path: str, + target_id: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Validation uses the right target segment for both endpoint + shapes. + """ + database = _database_with_target(target_id=target_id) + + monkeypatch.setattr( + target=target_validators, + name="get_database_matching_server_keys", + value=partial(_always_match_database, database=database), + ) + + validate_target_id_exists( + request_path=request_path, + request_headers={}, + request_body=b"", + request_method="GET", + databases={database}, + ) diff --git a/tests/mock_vws/test_unexpected_json.py b/tests/mock_vws/test_unexpected_json.py index cb77caf30..497119268 100644 --- a/tests/mock_vws/test_unexpected_json.py +++ b/tests/mock_vws/test_unexpected_json.py @@ -1,85 +1,88 @@ -""" -Tests for giving JSON data to endpoints which do not expect it. -""" +"""Tests for giving JSON data to endpoints which do not expect it.""" import json from http import HTTPStatus from urllib.parse import urlparse import pytest -import requests -from requests.structures import CaseInsensitiveDict from vws_auth_tools import authorization_header, rfc_1123_date from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import assert_vwq_failure +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUnexpectedJSON: - """ - Tests for giving JSON to endpoints which do not expect it. - """ + """Tests for giving JSON to endpoints which do not expect it.""" - def test_does_not_take_data( - self, - endpoint: Endpoint, - ) -> None: + @staticmethod + def test_does_not_take_data(endpoint: Endpoint) -> None: """ - Giving JSON to endpoints which do not take any JSON data returns error + Giving JSON to endpoints which do not take any JSON data returns + error responses. """ if ( - endpoint.prepared_request.headers.get( - 'Content-Type', + endpoint.headers.get( + "Content-Type", ) - == 'application/json' + == "application/json" ): return - content = bytes(json.dumps({'key': 'value'}), encoding='utf-8') - content_type = 'application/json' + content = json.dumps(obj={"key": "value"}).encode(encoding="utf-8") + content_type = "application/json" date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), + method=endpoint.method, content=content, content_type=content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, + "Content-Length": str(object=len(content)), } - endpoint.prepared_request.body = content - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - endpoint.prepared_request.prepare_content_length(body=content) - session = requests.Session() - response = session.send( # type: ignore - request=endpoint.prepared_request, + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + response = new_endpoint.send() + + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": # The multipart/formdata boundary is no longer in the given # content. - assert response.text == '' + assert not response.text assert_vwq_failure( response=response, status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, content_type=None, + cache_control=None, + www_authenticate=None, + connection="keep-alive", ) return assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.text == '' - assert 'Content-Type' not in response.headers + assert not response.text + assert "Content-Type" not in response.headers diff --git a/tests/mock_vws/test_update_target.py b/tests/mock_vws/test_update_target.py index 29e229a84..4bf199f0c 100644 --- a/tests/mock_vws/test_update_target.py +++ b/tests/mock_vws/test_update_target.py @@ -1,45 +1,49 @@ -""" -Tests for the mock of the update target endpoint. -""" +"""Tests for the mock of the update target endpoint.""" import base64 import io import json import uuid -from http import HTTPStatus -from typing import Any, Dict, Union -from urllib.parse import urljoin +from http import HTTPMethod, HTTPStatus +from typing import Any, Final import pytest -import requests -from requests import Response -from requests_mock import PUT from vws import VWS -from vws.exceptions import BadImage, ProjectInactive +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + BadImageError, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectInactiveError, + TargetNameExistError, + TargetStatusNotSuccessError, +) from vws.reports import TargetStatuses -from vws_auth_tools import authorization_header, rfc_1123_date +from vws.response import Response from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase from tests.mock_vws.utils import make_image_file from tests.mock_vws.utils.assertions import ( assert_vws_failure, assert_vws_response, ) +_MAX_METADATA_BYTES: Final[int] = 1024 * 1024 - 1 + -def update_target( - vuforia_database: VuforiaDatabase, - data: Dict[str, Any], +def _update_target( + *, + vws_client: VWS, + data: dict[str, Any], target_id: str, - content_type: str = 'application/json', + content_type: str, ) -> Response: - """ - Make a request to the endpoint to update a target. + """Make a request to the endpoint to update a target. Args: - vuforia_database: The credentials to use to connect to - Vuforia. + vws_client: The client to use to connect to Vuforia. data: The data to send, in JSON format, to the endpoint. target_id: The ID of the target to update. content_type: The `Content-Type` header to use. @@ -47,132 +51,114 @@ def update_target( Returns: The response returned by the API. """ - date = rfc_1123_date() - request_path = '/targets/' + target_id - - content = bytes(json.dumps(data), encoding='utf-8') - - authorization_string = authorization_header( - access_key=vuforia_database.server_access_key, - secret_key=vuforia_database.server_secret_key, - method=PUT, - content=content, - content_type=content_type, - date=date, - request_path=request_path, - ) - - headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, - } - - response = requests.request( - method=PUT, - url=urljoin('https://vws.vuforia.com/', request_path), - headers=headers, + content = json.dumps(obj=data).encode(encoding="utf-8") + return vws_client.make_request( + method=HTTPMethod.PUT, data=content, + request_path=f"/targets/{target_id}", + expected_result_code=ResultCodes.SUCCESS.value, + content_type=content_type, ) - return response - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUpdate: - """ - Tests for updating targets. - """ + """Tests for updating targets.""" + @staticmethod @pytest.mark.parametrize( - 'content_type', - [ + argnames="content_type", + argvalues=[ # This is the documented required content type: - 'application/json', + "application/json", # Other content types also work. - 'other/content_type', + "other/content_type", ], - ids=['Documented Content-Type', 'Undocumented Content-Type'], + ids=["Documented Content-Type", "Undocumented Content-Type"], ) def test_content_types( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, content_type: str, ) -> None: """ - The ``Content-Type`` header does not change the response as long as it + The ``Content-Type`` header does not change the response as long + as it is not empty. """ target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file_failed_state, active_flag=True, application_metadata=None, ) - response = update_target( - vuforia_database=vuforia_database, - data={'name': 'Adam'}, - target_id=target_id, - content_type=content_type, - ) + with pytest.raises( + expected_exception=TargetStatusNotSuccessError + ) as exc: + _update_target( + vws_client=vws_client, + data={"name": "Adam"}, + target_id=target_id, + content_type=content_type, + ) # Code is FORBIDDEN because the target is processing. assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.TARGET_STATUS_NOT_SUCCESS, ) + @staticmethod def test_empty_content_type( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - An ``UNAUTHORIZED`` response is given if an empty ``Content-Type`` + An ``UNAUTHORIZED`` response is given if an empty ``Content- + Type`` header is given. """ target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file_failed_state, active_flag=True, application_metadata=None, ) - response = update_target( - vuforia_database=vuforia_database, - data={'name': 'Adam'}, - target_id=target_id, - content_type='', - ) + with pytest.raises( + expected_exception=AuthenticationFailureError + ) as exc: + _update_target( + vws_client=vws_client, + data={"name": "Adam"}, + target_id=target_id, + content_type="", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNAUTHORIZED, result_code=ResultCodes.AUTHENTICATION_FAILURE, ) + @staticmethod def test_no_fields_given( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, ) -> None: - """ - No data fields are required. - """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, + """No data fields are required.""" + response = _update_target( + vws_client=vws_client, data={}, target_id=target_id, + content_type="application/json", ) assert_vws_response( @@ -181,7 +167,9 @@ def test_no_fields_given( result_code=ResultCodes.SUCCESS, ) - assert response.json().keys() == {'result_code', 'transaction_id'} + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"result_code", "transaction_id"} target_details = vws_client.get_target_record(target_id=target_id) # Targets go back to processing after being updated. @@ -193,70 +181,65 @@ def test_no_fields_given( assert target_details.status == TargetStatuses.SUCCESS -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUnexpectedData: - """ - Tests for passing data which is not allowed to the endpoint. - """ + """Tests for passing data which is not allowed to the endpoint.""" + @staticmethod def test_invalid_extra_data( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, ) -> None: """ - A `BAD_REQUEST` response is returned when unexpected data is given. + A `BAD_REQUEST` response is returned when unexpected data is + given. """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'extra_thing': 1}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"extra_thing": 1}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestWidth: - """ - Tests for the target width field. - """ + """Tests for the target width field.""" + @staticmethod @pytest.mark.parametrize( - 'width', - [-1, '10', None, 0], - ids=['Negative', 'Wrong Type', 'None', 'Zero'], + argnames="width", + argvalues=[-1, "10", None, 0], + ids=["Negative", "Wrong Type", "None", "Zero"], ) def test_width_invalid( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, - width: Any, + width: int | str | None, target_id: str, ) -> None: - """ - The width must be a number greater than zero. - """ - vws_client.wait_for_target_processed(target_id=target_id) - + """The width must be a number greater than zero.""" target_details = vws_client.get_target_record(target_id=target_id) original_width = target_details.target_record.width - response = update_target( - vuforia_database=vuforia_database, - data={'width': width}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"width": width}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) @@ -264,36 +247,36 @@ def test_width_invalid( target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.width == original_width - def test_width_valid(self, vws_client: VWS, target_id: str) -> None: - """ - Positive numbers are valid widths. - """ - vws_client.wait_for_target_processed(target_id=target_id) - + @staticmethod + def test_width_valid(*, vws_client: VWS, target_id: str) -> None: + """Positive numbers are valid widths.""" width = 0.01 vws_client.update_target(target_id=target_id, width=width) target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.width == width -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for the active flag parameter. - """ + """Tests for the active flag parameter.""" - @pytest.mark.parametrize('initial_active_flag', [True, False]) - @pytest.mark.parametrize('desired_active_flag', [True, False]) + @staticmethod + @pytest.mark.parametrize( + argnames="initial_active_flag", + argvalues=[True, False], + ) + @pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=[True, False], + ) def test_active_flag( - self, + *, vws_client: VWS, image_file_success_state_low_rating: io.BytesIO, initial_active_flag: bool, desired_active_flag: bool, ) -> None: - """ - Setting the active flag to a Boolean value changes it. - """ + """Setting the active flag to a Boolean value changes it.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -311,211 +294,196 @@ def test_active_flag( target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.active_flag == desired_active_flag - @pytest.mark.parametrize('desired_active_flag', ['string', None]) + @staticmethod + @pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=["string", None], + ) def test_invalid( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, - desired_active_flag: Union[str, None], + desired_active_flag: str | None, ) -> None: """ - Values which are not Boolean values are not valid active flags. + Values which are not Boolean values are not valid active + flags. """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'active_flag': desired_active_flag}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"active_flag": desired_active_flag}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestApplicationMetadata: - """ - Tests for the application metadata parameter. - """ - - _MAX_METADATA_BYTES = 1024 * 1024 - 1 + """Tests for the application metadata parameter.""" + @staticmethod @pytest.mark.parametrize( - 'metadata', - [ - b'a', - b'a' * _MAX_METADATA_BYTES, + argnames="metadata", + argvalues=[ + b"a", + b"a" * _MAX_METADATA_BYTES, ], - ids=['Short', 'Max length'], + ids=["Short", "Max length"], ) def test_base64_encoded( - self, + *, target_id: str, metadata: bytes, vws_client: VWS, ) -> None: - """ - A base64 encoded string is valid application metadata. - """ - metadata_encoded = base64.b64encode(metadata).decode('ascii') - vws_client.wait_for_target_processed(target_id=target_id) + """A base64 encoded string is valid application metadata.""" + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) vws_client.update_target( target_id=target_id, application_metadata=metadata_encoded, ) - @pytest.mark.parametrize('invalid_metadata', [1, None]) + @staticmethod + @pytest.mark.parametrize(argnames="invalid_metadata", argvalues=[1, None]) def test_invalid_type( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, - invalid_metadata: Union[int, None], + invalid_metadata: int | None, ) -> None: - """ - Non-string values cannot be given as valid application metadata. - """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': invalid_metadata}, - target_id=target_id, - ) + """Non-string values cannot be given as valid application metadata.""" + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"application_metadata": invalid_metadata}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, not_base64_encoded_processable: str, ) -> None: """ - Some strings which are not valid base64 encoded strings are allowed as + Some strings which are not valid base64 encoded strings are + allowed as application metadata. """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': not_base64_encoded_processable}, + vws_client.update_target( target_id=target_id, + application_metadata=not_base64_encoded_processable, ) - assert_vws_response( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) - + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, not_base64_encoded_not_processable: str, ) -> None: """ - Some strings which are not valid base64 encoded strings are not allowed + Some strings which are not valid base64 encoded strings are not + allowed as application metadata. """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': not_base64_encoded_not_processable}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + vws_client.update_target( + target_id=target_id, + application_metadata=not_base64_encoded_not_processable, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) - def test_metadata_too_large( - self, - vuforia_database: VuforiaDatabase, - vws_client: VWS, - target_id: str, - ) -> None: + @staticmethod + def test_metadata_too_large(*, vws_client: VWS, target_id: str) -> None: """ - A base64 encoded string of greater than 1024 * 1024 bytes is too large + A base64 encoded string of greater than 1024 * 1024 bytes is too + large for application metadata. """ - metadata = b'a' * (self._MAX_METADATA_BYTES + 1) - metadata_encoded = base64.b64encode(metadata).decode('ascii') - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': metadata_encoded}, - target_id=target_id, + metadata = b"a" * (_MAX_METADATA_BYTES + 1) + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" ) + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: + vws_client.update_target( + target_id=target_id, + application_metadata=metadata_encoded, + ) + assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.METADATA_TOO_LARGE, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetName: - """ - Tests for the target name field. - """ + """Tests for the target name field.""" _MAX_CHAR_VALUE = 65535 _MAX_NAME_LENGTH = 64 + @staticmethod @pytest.mark.parametrize( - 'name', - [ - 'á', + argnames="name", + argvalues=[ + "á", # We test just below the max character value. # This is because targets with the max character value in their # names get stuck in the processing stage. chr(_MAX_CHAR_VALUE - 2), - 'a' * _MAX_NAME_LENGTH, + "a" * _MAX_NAME_LENGTH, ], - ids=['Short name', 'Max char value', 'Long name'], + ids=["Short name", "Max char value", "Long name"], ) def test_name_valid( - self, + *, name: str, target_id: str, vws_client: VWS, ) -> None: - """ - A target's name must be a string of length 0 < N < 65. + """A target's name must be a string of length 0 < N < 65. We test characters out of range in another test as that gives a different error. """ - vws_client.wait_for_target_processed(target_id=target_id) vws_client.update_target(target_id=target_id, name=name) target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.name == name + @staticmethod @pytest.mark.parametrize( - 'name,status_code,result_code', - [ + argnames=("name", "status_code", "result_code"), + argvalues=[ (1, HTTPStatus.BAD_REQUEST, ResultCodes.FAIL), - ('', HTTPStatus.BAD_REQUEST, ResultCodes.FAIL), + ("", HTTPStatus.BAD_REQUEST, ResultCodes.FAIL), ( - 'a' * (_MAX_NAME_LENGTH + 1), + "a" * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST, ResultCodes.FAIL, ), @@ -532,51 +500,46 @@ def test_name_valid( ), ], ids=[ - 'Wrong Type', - 'Empty', - 'Too Long', - 'None', - 'Bad char', - 'Bad char too long', + "Wrong Type", + "Empty", + "Too Long", + "None", + "Bad char", + "Bad char too long", ], ) def test_name_invalid( - self, - name: str, + *, + name: str | int | None, target_id: str, - vuforia_database: VuforiaDatabase, vws_client: VWS, status_code: int, result_code: ResultCodes, ) -> None: - """ - A target's name must be a string of length 0 < N < 65. - """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'name': name}, - target_id=target_id, - ) + """A target's name must be a string of length 0 < N < 65.""" + with pytest.raises(expected_exception=VWSError) as exc: + _update_target( + vws_client=vws_client, + data={"name": name}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=status_code, result_code=result_code, ) + @staticmethod def test_existing_target_name( - self, + *, image_file_success_state_low_rating: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - Only one target can have a given name. - """ - first_target_name = 'example_name' - second_target_name = 'another_example_name' + """Only one target can have a given name.""" + first_target_name = "example_name" + second_target_name = "another_example_name" first_target_id = vws_client.add_target( name=first_target_name, @@ -597,28 +560,26 @@ def test_existing_target_name( vws_client.wait_for_target_processed(target_id=first_target_id) vws_client.wait_for_target_processed(target_id=second_target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'name': first_target_name}, - target_id=second_target_id, - ) + with pytest.raises(expected_exception=TargetNameExistError) as exc: + vws_client.update_target( + target_id=second_target_id, + name=first_target_name, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.TARGET_NAME_EXIST, ) + @staticmethod def test_same_name_given( - self, + *, image_file_success_state_low_rating: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - Updating a target with its own name does not give an error. - """ - name = 'example' + """Updating a target with its own name does not give an error.""" + name = "example" target_id = vws_client.add_target( name=name, @@ -629,116 +590,92 @@ def test_same_name_given( ) vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'name': name}, - target_id=target_id, - ) - - assert_vws_failure( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) - + vws_client.update_target(target_id=target_id, name=name) target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.name == name -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestImage: - """ - Tests for the image parameter. + """Tests for the image parameter. - The specification for images is documented in "Supported Images" on - https://library.vuforia.com/articles/Training/Image-Target-Guide + The specification for images is documented at + https://library.vuforia.com/features/images/image-targets.html. """ + @staticmethod def test_image_valid( - self, - vuforia_database: VuforiaDatabase, + *, image_files_failed_state: io.BytesIO, target_id: str, vws_client: VWS, ) -> None: """ - JPEG and PNG files in the RGB and greyscale color spaces are allowed. + JPEG and PNG files in the RGB and greyscale color spaces are + allowed. """ - image_file = image_files_failed_state - image_data = image_file.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, + vws_client.update_target( target_id=target_id, + image=image_files_failed_state, ) - assert_vws_response( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) - + @staticmethod def test_bad_image_format_or_color_space( - self, + *, bad_image_file: io.BytesIO, target_id: str, vws_client: VWS, ) -> None: """ - A `BAD_IMAGE` response is returned if an image which is not a JPEG - or PNG file is given, or if the given image is not in the greyscale or - RGB color space. + A `BAD_IMAGE` response is returned if an image which is not a + JPEG or + PNG file is given, or if the given image is not in the greyscale or + RGB + color space. """ - vws_client.wait_for_target_processed(target_id=target_id) - with pytest.raises(BadImage) as exc: + with pytest.raises(expected_exception=BadImageError) as exc: vws_client.update_target(target_id=target_id, image=bad_image_file) status_code = exc.value.response.status_code assert status_code == HTTPStatus.UNPROCESSABLE_ENTITY + @staticmethod def test_corrupted( - self, + *, vws_client: VWS, corrupted_image_file: io.BytesIO, target_id: str, ) -> None: - """ - No error is returned when the given image is corrupted. - """ - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.update_target( - target_id=target_id, - image=corrupted_image_file, + """An error is returned when the given image is corrupted.""" + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=corrupted_image_file, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.BAD_IMAGE, ) - def test_image_too_large( - self, - vuforia_database: VuforiaDatabase, - target_id: str, - vws_client: VWS, - ) -> None: + @staticmethod + def test_image_too_large(*, target_id: str, vws_client: VWS) -> None: """ - An `ImageTooLarge` result is returned if the image is above a certain - threshold. + An `ImageTooLargeError` result is returned if the image is above + a + certain threshold. """ max_bytes = 2.3 * 1024 * 1024 width = height = 886 png_not_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) - vws_client.wait_for_target_processed(target_id=target_id) - - image_data = png_not_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_not_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -748,31 +685,20 @@ def test_image_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, - target_id=target_id, - ) - - assert_vws_response( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) + vws_client.update_target(target_id=target_id, image=png_not_too_large) vws_client.wait_for_target_processed(target_id=target_id) width = width + 1 height = height + 1 png_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) - image_data = png_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -782,141 +708,129 @@ def test_image_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, - target_id=target_id, - ) + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.update_target(target_id=target_id, image=png_too_large) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.IMAGE_TOO_LARGE, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, target_id: str, not_base64_encoded_processable: str, - vws_client: VWS, ) -> None: - """ - Some strings which are not valid base64 encoded strings are allowed as - an image without getting a "Fail" response. - This is because Vuforia treats them as valid base64, but then not a - valid image. - """ - vws_client.wait_for_target_processed(target_id=target_id) + """Some strings which are not valid base64 encoded strings are + allowed + as an image without getting a "Fail" response. - response = update_target( - vuforia_database=vuforia_database, - data={'image': not_base64_encoded_processable}, - target_id=target_id, - ) + This is because Vuforia treats them as valid base64, but then + not a valid image. + """ + with pytest.raises(expected_exception=BadImageError) as exc: + _update_target( + vws_client=vws_client, + data={"image": not_base64_encoded_processable}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, not_base64_encoded_not_processable: str, ) -> None: """ Some strings which are not valid base64 encoded strings are not - processable by Vuforia, and then when given as an image Vuforia returns + processable by Vuforia, and then when given as an image Vuforia + returns a "Fail" response. """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'image': not_base64_encoded_not_processable}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"image": not_base64_encoded_not_processable}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) - def test_not_image( - self, - vuforia_database: VuforiaDatabase, - target_id: str, - vws_client: VWS, - ) -> None: + @staticmethod + def test_not_image(*, target_id: str, vws_client: VWS) -> None: """ - If the given image is not an image file then a `BadImage` result is - returned. + If the given image is not an image file then a `BadImageError` + result + is returned. """ - not_image_data = b'not_image_data' - image_data_encoded = base64.b64encode(not_image_data).decode('ascii') - - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, - target_id=target_id, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=io.BytesIO(initial_bytes=b"not_image_data"), + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) - @pytest.mark.parametrize('invalid_type_image', [1, None]) + @staticmethod + @pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], + ) def test_invalid_type( - self, - invalid_type_image: Union[int, None], + *, + invalid_type_image: int | None, target_id: str, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - If the given image is not a string, a `Fail` result is returned. - """ - vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'image': invalid_type_image}, - target_id=target_id, - ) + """If the given image is not a string, a `Fail` result is returned.""" + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"image": invalid_type_image}, + target_id=target_id, + content_type="application/json", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_rating_can_change( - self, + *, image_file_success_state_low_rating: io.BytesIO, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - If the target is updated with an image of different quality, the + """If the target is updated with an image of different quality, the tracking rating can change. - "quality" refers to Vuforia's internal rating system. - The mock randomly assigns a quality and makes sure that the new quality + "quality" refers to Vuforia's internal rating system. The mock + randomly assigns a quality and makes sure that the new quality is different to the old quality. """ - good_image = high_quality_image.read() - good_image_data_encoded = base64.b64encode(good_image).decode('ascii') - target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -933,11 +847,7 @@ def test_rating_can_change( original_tracking_rating = target_details.target_record.tracking_rating assert original_tracking_rating in range(6) - update_target( - vuforia_database=vuforia_database, - data={'image': good_image_data_encoded}, - target_id=target_id, - ) + vws_client.update_target(target_id=target_id, image=high_quality_image) vws_client.wait_for_target_processed(target_id=target_id) target_details = vws_client.get_target_record(target_id=target_id) @@ -949,18 +859,15 @@ def test_rating_can_change( assert original_tracking_rating != new_tracking_rating -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - with pytest.raises(ProjectInactive): + with pytest.raises(expected_exception=ProjectInactiveError): inactive_vws_client.update_target(target_id=uuid.uuid4().hex) diff --git a/tests/mock_vws/test_usage.py b/tests/mock_vws/test_usage.py deleted file mode 100644 index e9ffcfce1..000000000 --- a/tests/mock_vws/test_usage.py +++ /dev/null @@ -1,618 +0,0 @@ -""" -Tests for the usage of the mock. -""" - -import email.utils -import io -import socket -from datetime import datetime, timedelta - -import pytest -import requests -from freezegun import freeze_time -from requests.exceptions import MissingSchema -from requests_mock.exceptions import NoMockAddress -from vws import VWS, CloudRecoService -from vws.exceptions import MatchProcessing -from vws.reports import TargetStatuses -from vws_auth_tools import rfc_1123_date - -from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase -from mock_vws.states import States - - -def request_unmocked_address() -> None: - """ - Make a request, using `requests` to an unmocked, free local address. - - Raises: - requests.exceptions.ConnectionError: This is expected as there is - nothing to connect to. - requests_mock.exceptions.NoMockAddress: This request is being made in - the context of a `requests_mock` mock which does not mock local - addresses. - """ - sock = socket.socket() - sock.bind(('', 0)) - port = sock.getsockname()[1] - sock.close() - address = f'http://localhost:{port}' - requests.get(address) - - -def request_mocked_address() -> None: - """ - Make a request, using `requests` to an address that is mocked by `MockVWS`. - """ - requests.get( - url='https://vws.vuforia.com/summary', - headers={ - 'Date': rfc_1123_date(), - 'Authorization': 'bad_auth_token', - }, - data=b'', - ) - - -class TestRealHTTP: - """ - Tests for making requests to mocked and unmocked addresses. - """ - - def test_default(self) -> None: - """ - By default, the mock stops any requests made with `requests` to - non-Vuforia addresses, but not to mocked Vuforia endpoints. - """ - with MockVWS(): - with pytest.raises(NoMockAddress): - request_unmocked_address() - - # No exception is raised when making a request to a mocked - # endpoint. - request_mocked_address() - - # The mocking stops when the context manager stops. - with pytest.raises(requests.exceptions.ConnectionError): - request_unmocked_address() - - def test_real_http(self) -> None: - """ - When the `real_http` parameter given to the context manager is set to - `True`, requests made to unmocked addresses are not stopped. - """ - with MockVWS(real_http=True): - with pytest.raises(requests.exceptions.ConnectionError): - request_unmocked_address() - - -class TestProcessingTime: - """ - Tests for the time taken to process targets in the mock. - """ - - def test_default(self, image_file_failed_state: io.BytesIO) -> None: - """ - By default, targets in the mock take 0.5 seconds to be processed. - """ - database = VuforiaDatabase() - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - with MockVWS() as mock: - mock.add_database(database=database) - - target_id = vws_client.add_target( - name='example', - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - start_time = datetime.now() - - while True: - target_details = vws_client.get_target_record( - target_id=target_id, - ) - - status = target_details.status - if status != TargetStatuses.PROCESSING: - elapsed_time = datetime.now() - start_time - # There is a race condition in this test - if it starts to - # fail, maybe extend the acceptable range. - assert elapsed_time < timedelta(seconds=0.55) - assert elapsed_time > timedelta(seconds=0.49) - return - - def test_custom(self, image_file_failed_state: io.BytesIO) -> None: - """ - It is possible to set a custom processing time. - """ - database = VuforiaDatabase() - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - with MockVWS(processing_time_seconds=0.1) as mock: - mock.add_database(database=database) - target_id = vws_client.add_target( - name='example', - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - start_time = datetime.now() - - while True: - target_details = vws_client.get_target_record( - target_id=target_id, - ) - - status = target_details.status - if status != TargetStatuses.PROCESSING: - elapsed_time = datetime.now() - start_time - assert elapsed_time < timedelta(seconds=0.15) - assert elapsed_time > timedelta(seconds=0.09) - return - - -class TestDatabaseName: - """ - Tests for the database name. - """ - - def test_default(self) -> None: - """ - By default, the database has a random name. - """ - database_details = VuforiaDatabase() - other_database_details = VuforiaDatabase() - assert ( - database_details.database_name - != other_database_details.database_name - ) - - def test_custom_name(self) -> None: - """ - It is possible to set a custom database name. - """ - database_details = VuforiaDatabase(database_name='foo') - assert database_details.database_name == 'foo' - - -class TestCustomBaseURLs: - """ - Tests for using custom base URLs. - """ - - def test_custom_base_vws_url(self) -> None: - """ - It is possible to use a custom base VWS URL. - """ - with MockVWS( - base_vws_url='https://vuforia.vws.example.com', - real_http=False, - ): - with pytest.raises(NoMockAddress): - requests.get('https://vws.vuforia.com/summary') - - requests.get(url='https://vuforia.vws.example.com/summary') - requests.post('https://cloudreco.vuforia.com/v1/query') - - def test_custom_base_vwq_url(self) -> None: - """ - It is possible to use a custom base cloud recognition URL. - """ - with MockVWS( - base_vwq_url='https://vuforia.vwq.example.com', - real_http=False, - ): - with pytest.raises(NoMockAddress): - requests.post('https://cloudreco.vuforia.com/v1/query') - - requests.post(url='https://vuforia.vwq.example.com/v1/query') - requests.get('https://vws.vuforia.com/summary') - - def test_no_scheme(self) -> None: - """ - An error if raised if a URL is given with no scheme. - """ - with pytest.raises(MissingSchema) as exc: - MockVWS(base_vws_url='vuforia.vws.example.com') - - expected = ( - 'Invalid URL "vuforia.vws.example.com": No scheme supplied. ' - 'Perhaps you meant "https://vuforia.vws.example.com".' - ) - assert str(exc.value) == expected - with pytest.raises(MissingSchema) as exc: - MockVWS(base_vwq_url='vuforia.vwq.example.com') - expected = ( - 'Invalid URL "vuforia.vwq.example.com": No scheme supplied. ' - 'Perhaps you meant "https://vuforia.vwq.example.com".' - ) - assert str(exc.value) == expected - - -def _add_and_delete_target( - image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> None: - """ - Add and delete a target with the given image. - """ - vws_client = VWS( - server_access_key=vuforia_database.server_access_key, - server_secret_key=vuforia_database.server_secret_key, - ) - - target_id = vws_client.add_target( - name='example_name', - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) - - -def _wait_for_deletion_recognized( - image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> None: - """ - Wait until the query endpoint "recognizes" the deletion of all targets with - an image matching the given image. - - That is, wait until querying the given image does not return a result with - targets. - """ - cloud_reco_client = CloudRecoService( - client_access_key=vuforia_database.client_access_key, - client_secret_key=vuforia_database.client_secret_key, - ) - - while True: - try: - results = cloud_reco_client.query(image=image) - except MatchProcessing: - return - - if not results: - return - - -def _wait_for_deletion_processed( - image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> None: - """ - Wait until the query endpoint "recognizes" the deletion of all targets with - an image matching the given image. - - That is, wait until querying the given image returns a result with no - targets. - """ - _wait_for_deletion_recognized( - image=image, - vuforia_database=vuforia_database, - ) - - cloud_reco_client = CloudRecoService( - client_access_key=vuforia_database.client_access_key, - client_secret_key=vuforia_database.client_secret_key, - ) - - while True: - try: - cloud_reco_client.query(image=image) - except MatchProcessing: - continue - return - - -class TestCustomQueryRecognizesDeletionSeconds: - """ - Tests for setting the amount of time after a target has been deleted - until it is not recognized by the query endpoint. - """ - - def _recognize_deletion_seconds( - self, - high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - ) -> float: - """ - The number of seconds it takes for the query endpoint to recognize a - deletion. - """ - _add_and_delete_target( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_after_deletion = datetime.now() - - _wait_for_deletion_recognized( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_difference = datetime.now() - time_after_deletion - return time_difference.total_seconds() - - def test_default( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - By default it takes zero seconds for the Query API on the mock to - recognize that a target has been deleted. - - The real Query API takes between zero and two seconds. - See ``test_query`` for more information. - """ - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - recognize_deletion_seconds = self._recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, - ) - - expected = 0.2 - assert abs(expected - recognize_deletion_seconds) < 0.15 - - def test_with_no_processing_time( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - This exercises some otherwise untouched code. - """ - database = VuforiaDatabase() - with MockVWS(query_processes_deletion_seconds=0) as mock: - mock.add_database(database=database) - recognize_deletion_seconds = self._recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, - ) - - expected = 0.2 - assert abs(expected - recognize_deletion_seconds) < 0.15 - - def test_custom( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - It is possible to use set a custom amount of time that it takes for the - Query API on the mock to recognize that a target has been deleted. - """ - # We choose a low time for a quick test. - query_recognizes_deletion = 0.5 - database = VuforiaDatabase() - with MockVWS( - query_recognizes_deletion_seconds=query_recognizes_deletion, - ) as mock: - mock.add_database(database=database) - recognize_deletion_seconds = self._recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, - ) - - expected = query_recognizes_deletion - assert abs(expected - recognize_deletion_seconds) < 0.15 - - -class TestCustomQueryProcessDeletionSeconds: - """ - Tests for setting the amount of time after a target has been deleted - until it is not processed by the query endpoint. - """ - - def _process_deletion_seconds( - self, - high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - ) -> float: - """ - The number of seconds it takes for the query endpoint to process a - deletion. - """ - _add_and_delete_target( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - _wait_for_deletion_recognized( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_after_deletion_recognized = datetime.now() - - _wait_for_deletion_processed( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_difference = datetime.now() - time_after_deletion_recognized - return time_difference.total_seconds() - - def test_default( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - By default it takes three seconds for the Query API on the mock to - process that a target has been deleted. - - The real Query API takes between seven and thirty seconds. - See ``test_query`` for more information. - """ - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - process_deletion_seconds = self._process_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, - ) - - expected = 3 - assert abs(expected - process_deletion_seconds) < 0.1 - - def test_custom( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - It is possible to use set a custom amount of time that it takes for the - Query API on the mock to process that a target has been deleted. - """ - # We choose a low time for a quick test. - query_processes_deletion = 0.1 - database = VuforiaDatabase() - with MockVWS( - query_processes_deletion_seconds=query_processes_deletion, - ) as mock: - mock.add_database(database=database) - process_deletion_seconds = self._process_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, - ) - - expected = query_processes_deletion - assert abs(expected - process_deletion_seconds) < 0.1 - - -class TestStates: - """ - Tests for different mock states. - """ - - def test_repr(self) -> None: - """ - Test for the representation of a ``State``. - """ - assert repr(States.WORKING) == '' - - -class TestTargets: - """ - Tests for target representations. - """ - - def test_repr(self, high_quality_image: io.BytesIO) -> None: - """ - Test for the representation of a ``Target``. - """ - database = VuforiaDatabase() - - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - with MockVWS() as mock: - mock.add_database(database=database) - target_id = vws_client.add_target( - name='example', - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - (target,) = database.targets - assert repr(target) == f'' - - -class TestDateHeader: - """ - Tests for the date header in responses from mock routes. - """ - - def test_date_changes(self) -> None: - """ - The date that the response is sent is in the response Date header. - """ - new_year = 2012 - new_time = datetime(new_year, 1, 1) - with MockVWS(): - with freeze_time(new_time): - response = requests.get('https://vws.vuforia.com/summary') - - date_response = response.headers['Date'] - date_from_response = email.utils.parsedate(date_response) - assert date_from_response is not None - year = date_from_response[0] - assert year == new_year - - -class TestAddDatabase: - """ - Tests for adding databases to the mock. - """ - - def test_duplicate_keys(self) -> None: - """ - It is not possible to have multiple databases with matching keys. - """ - with MockVWS() as mock: - mock.add_database(database=VuforiaDatabase(server_access_key='1')) - with pytest.raises(ValueError) as exc: - mock.add_database( - database=VuforiaDatabase(server_access_key='1'), - ) - - expected_message = ( - 'All server access keys must be unique. ' - 'There is already a database with the server access key "1".' - ) - assert str(exc.value) == expected_message - - with MockVWS() as mock: - mock.add_database(database=VuforiaDatabase(server_secret_key='1')) - with pytest.raises(ValueError) as exc: - mock.add_database( - database=VuforiaDatabase(server_secret_key='1'), - ) - - expected_message = ( - 'All server secret keys must be unique. ' - 'There is already a database with the server secret key "1".' - ) - assert str(exc.value) == expected_message - - with MockVWS() as mock: - mock.add_database(database=VuforiaDatabase(client_access_key='1')) - with pytest.raises(ValueError) as exc: - mock.add_database( - database=VuforiaDatabase(client_access_key='1'), - ) - - expected_message = ( - 'All client access keys must be unique. ' - 'There is already a database with the client access key "1".' - ) - assert str(exc.value) == expected_message - - with MockVWS() as mock: - mock.add_database(database=VuforiaDatabase(client_secret_key='1')) - with pytest.raises(ValueError) as exc: - mock.add_database( - database=VuforiaDatabase(client_secret_key='1'), - ) - - expected_message = ( - 'All client secret keys must be unique. ' - 'There is already a database with the client secret key "1".' - ) - assert str(exc.value) == expected_message diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py new file mode 100644 index 000000000..4421eb763 --- /dev/null +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -0,0 +1,409 @@ +"""Tests for the VuMark generation web API.""" + +import json +from http import HTTPMethod, HTTPStatus +from uuid import uuid4 + +import pytest +import requests +from beartype import beartype +from vws import VWS, VuMarkService +from vws.exceptions.vws_exceptions import ( + InvalidInstanceIdError, + InvalidTargetTypeError, + TargetStatusNotSuccessError, +) +from vws.vumark_accept import VuMarkAccept +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws._constants import ResultCodes +from mock_vws.database import CloudDatabase +from tests.mock_vws.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, + VuMarkCloudDatabase, +) +from tests.mock_vws.utils import make_image_file + +type _JsonValue = ( + str | int | float | bool | list[_JsonValue] | dict[str, _JsonValue] | None +) + +_VWS_HOST = "https://vws.vuforia.com" +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_PDF_SIGNATURE = b"%PDF" +_SVG_START = b"<" + + +@beartype +def _make_vumark_service( + *, + server_access_key: str, + server_secret_key: str, +) -> VuMarkService: + """Return a VuMark service client.""" + return VuMarkService( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + + +@beartype +def _make_vumark_request( + *, + server_access_key: str, + server_secret_key: str, + target_id: str, + instance_id: _JsonValue, + accept: str, +) -> requests.Response: + """Send a VuMark instance generation request and return the + response. + """ + request_path = f"/targets/{target_id}/instances" + content_type = "application/json" + content = json.dumps(obj={"instance_id": instance_id}).encode( + encoding="utf-8" + ) + date = rfc_1123_date() + authorization_string = authorization_header( + access_key=server_access_key, + secret_key=server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + return requests.post( + url=_VWS_HOST + request_path, + headers={ + "Accept": accept, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + }, + data=content, + timeout=30, + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestGenerateInstance: + """Tests for the VuMark instance generation endpoint.""" + + @pytest.mark.parametrize( + argnames=("accept", "expected_signature"), + argvalues=[ + pytest.param(VuMarkAccept.PNG, _PNG_SIGNATURE, id="png"), + pytest.param( + VuMarkAccept.SVG, + _SVG_START, + id="svg", + ), + pytest.param( + VuMarkAccept.PDF, + _PDF_SIGNATURE, + id="pdf", + ), + ], + ) + @staticmethod + def test_generate_instance_format( + *, + accept: VuMarkAccept, + expected_signature: bytes, + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """A VuMark instance can be generated in the requested format.""" + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=accept, + ) + + assert vumark_bytes.strip().startswith(expected_signature) + assert len(vumark_bytes) > len(expected_signature) + + @pytest.mark.parametrize( + argnames=("accept", "expected_content_type"), + argvalues=[ + pytest.param("image/png", "image/png", id="png"), + pytest.param("image/svg+xml", "image/svg+xml", id="svg"), + pytest.param("application/pdf", "application/pdf", id="pdf"), + ], + ) + @staticmethod + def test_generate_instance_content_type_header( + *, + accept: str, + expected_content_type: str, + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """VuMark image responses include the expected content type.""" + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=accept, + ) + + assert response.status_code == HTTPStatus.OK + assert ( + response.headers["Content-Type"].split(sep=";")[0] + == expected_content_type + ) + + @staticmethod + def test_invalid_accept_header( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """An unsupported Accept header returns an error.""" + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept="text/plain", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_ACCEPT_HEADER.value + ) + + @staticmethod + def test_empty_instance_id( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """An empty instance_id returns InvalidInstanceId.""" + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id="", + accept=VuMarkAccept.PNG, + ) + + assert ( + exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + ) + response_json = json.loads(s=exc.value.response.text) + assert ( + response_json["result_code"] + == ResultCodes.INVALID_INSTANCE_ID.value + ) + + @pytest.mark.parametrize( + argnames="instance_id", + argvalues=[ + pytest.param(5, id="int"), + pytest.param(0, id="zero"), + pytest.param(0.0, id="zero_float"), + pytest.param(1.5, id="float"), + pytest.param(True, id="true"), + pytest.param(False, id="false"), + pytest.param([1], id="array"), + pytest.param([], id="empty_array"), + pytest.param({"a": 1}, id="object"), + pytest.param({}, id="empty_object"), + pytest.param(None, id="null"), + ], + ) + @staticmethod + def test_non_string_instance_id( + *, + instance_id: _JsonValue, + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """An instance_id which is not a string returns BadRequest.""" + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=instance_id, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert response_json["result_code"] == ResultCodes.BAD_REQUEST.value + + @staticmethod + def test_unknown_target( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """An unknown target_id returns UnknownTarget.""" + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=uuid4().hex, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + response_json = response.json() + assert response_json["result_code"] == ResultCodes.UNKNOWN_TARGET.value + + @staticmethod + def test_non_vumark_database( + vuforia_database: CloudDatabase, + ) -> None: + """Generating a VuMark instance for a target in a non-VuMark + database returns InvalidTargetType. + """ + server_access_key = vuforia_database.server_access_key + server_secret_key = vuforia_database.server_secret_key + vws_client = VWS( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + vumark_client = _make_vumark_service( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + image = make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ) + target_id = vws_client.add_target( + name="test", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: + vumark_client.generate_vumark_instance( + target_id=target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert ( + exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + ) + response_json = json.loads(s=exc.value.response.text) + assert ( + response_json["result_code"] + == ResultCodes.INVALID_TARGET_TYPE.value + ) + + @staticmethod + def test_successful_target( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """A VuMark target that has finished processing succeeds.""" + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert vumark_bytes.strip().startswith(_PNG_SIGNATURE) + + +# VuMark targets cannot be added via the VWS API — they are configured +# through the Vuforia Target Manager portal. This means we cannot +# create a target that is perpetually in PROCESSING state against real +# Vuforia. The mock controls processing time via the +# ``processing_time_seconds`` attribute on ``VuMarkTarget``, so these +# tests are inherently mock-only. +@pytest.mark.usefixtures("mock_only_vuforia") +class TestProcessingTarget: + """Tests for VuMark generation when the target is still processing. + + These use ``mock_only_vuforia`` because there is no way to keep a + VuMark target in PROCESSING state indefinitely on real Vuforia. + """ + + @staticmethod + def test_processing_target( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """A VuMark target still processing returns + TargetStatusNotSuccess. + """ + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + with pytest.raises( + expected_exception=TargetStatusNotSuccessError, + ) as exc: + vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.processing_target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + response_json = json.loads(s=exc.value.response.text) + assert ( + response_json["result_code"] + == ResultCodes.TARGET_STATUS_NOT_SUCCESS.value + ) + + @staticmethod + def test_processing_target_raw_response( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """The raw HTTP response for a processing target has the expected + status code and result code. + """ + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.processing_target_id, + instance_id=uuid4().hex, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.TARGET_STATUS_NOT_SUCCESS.value + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestInactiveDatabase: + """Tests for VuMark generation with an inactive database.""" + + @staticmethod + def test_inactive_database( + inactive_vumark_database: InactiveVuMarkCloudDatabase, + ) -> None: + """Calling the VuMark generation API with credentials for an + inactive database returns ProjectInactive. + """ + response = _make_vumark_request( + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + target_id=uuid4().hex, + instance_id=uuid4().hex, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + response_json = response.json() + assert response_json["result_code"] == ResultCodes.UNKNOWN_TARGET.value diff --git a/tests/mock_vws/test_vumark_generation_failure.py b/tests/mock_vws/test_vumark_generation_failure.py new file mode 100644 index 000000000..2600b5f82 --- /dev/null +++ b/tests/mock_vws/test_vumark_generation_failure.py @@ -0,0 +1,79 @@ +"""Tests for configurable VuMark generation failures.""" + +from collections.abc import Callable +from http import HTTPStatus + +import httpx +import pytest +import requests + +from mock_vws import MockVWS, VuMarkGenerationFailure + +_VUMARK_URL = "https://vws.vuforia.com/targets/example/instances" +_REQUEST_BODY = b'{"instance_id":"example"}' +type _HTTPResponse = requests.Response | httpx.Response +type _RequestSender = Callable[[], _HTTPResponse] + + +def _requests_request() -> _HTTPResponse: + """Send a VuMark generation request with ``requests``.""" + return requests.post( + url=_VUMARK_URL, + headers={ + "Accept": "image/png", + "Content-Type": "application/json", + }, + data=_REQUEST_BODY, + timeout=30, + ) + + +def _httpx_request() -> _HTTPResponse: + """Send a VuMark generation request with ``httpx``.""" + return httpx.post( + url=_VUMARK_URL, + headers={ + "Accept": "image/png", + "Content-Type": "application/json", + }, + content=_REQUEST_BODY, + timeout=30, + ) + + +@pytest.mark.parametrize( + argnames="send_request", + argvalues=[_requests_request, _httpx_request], + ids=["requests", "httpx"], +) +@pytest.mark.parametrize( + argnames=("failure", "expected_status_code"), + argvalues=[ + ( + VuMarkGenerationFailure.QUOTA_EXCEEDED, + HTTPStatus.FORBIDDEN, + ), + ( + VuMarkGenerationFailure.LICENSE_CHECK_FAILED, + HTTPStatus.FORBIDDEN, + ), + ( + VuMarkGenerationFailure.AUTHORIZATION_FAILED, + HTTPStatus.UNAUTHORIZED, + ), + ], +) +def test_configured_failure_response( + *, + send_request: _RequestSender, + failure: VuMarkGenerationFailure, + expected_status_code: HTTPStatus, +) -> None: + """Both in-process backends return the configured failure.""" + with MockVWS(vumark_generation_failure=failure): + response = send_request() + + assert response.status_code == expected_status_code + assert response.headers["Content-Type"] == "application/json" + assert response.json()["result_code"] == failure.value + assert response.json()["transaction_id"] diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 2fb507616..69ffb607d 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -1,84 +1,164 @@ -""" -Utilities for tests. -""" +"""Utilities for tests.""" import io -import random +import secrets +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal +from urllib.parse import urljoin import requests +from beartype import beartype from PIL import Image +from requests.structures import CaseInsensitiveDict +from vws.response import Response from mock_vws._constants import ResultCodes +@beartype +def _send_request( + *, + method: str, + url: str, + headers: Mapping[str, str], + data: bytes | str, +) -> Response: + """Send a request with exactly the given headers.""" + request = requests.Request( + method=method, + url=url, + headers=headers, + data=data, + ) + prepared_request = request.prepare() + prepared_request.headers = CaseInsensitiveDict(data=headers) + session = requests.Session() + requests_response = session.send(request=prepared_request) + return Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + + +@dataclass(frozen=True, kw_only=True) class Endpoint: - """ - Details of endpoints to be called in tests. + """Details of endpoints to be called in tests. + + Args: + prepared_request: A request to make which would be successful. + successful_headers_result_code: The expected result code if the + example path is requested with the method. + successful_headers_status_code: The expected status code if the + example path is requested with the method. + access_key: The access key used in the prepared request. + secret_key: The secret key used in the prepared request. + path_url: The path of the endpoint. + base_url: The base URL of the endpoint. + + Attributes: + prepared_request: A request to make which would be successful. + successful_headers_result_code: The expected result code if the + example path is requested with the method. + successful_headers_status_code: The expected status code if the + example path is requested with the method. + access_key: The access key used in the prepared request. + secret_key: The secret key used in the prepared request. + path_url: The path of the endpoint. + base_url: The base URL of the endpoint. """ - prepared_request: requests.PreparedRequest - successful_headers_result_code: ResultCodes + base_url: str + path_url: str + method: str + headers: Mapping[str, str] + data: bytes | str + successful_headers_result_code: ResultCodes | None successful_headers_status_code: int - auth_header_content_type: str access_key: str secret_key: str - def __init__( - self, - prepared_request: requests.PreparedRequest, - successful_headers_result_code: ResultCodes, - successful_headers_status_code: int, - access_key: str, - secret_key: str, - ) -> None: - """ - Args: - prepared_request: A request to make which would be successful. - successful_headers_result_code: The expected result code if the - example path is requested with the method. - successful_headers_status_code: The expected status code if the - example path is requested with the method. - access_key: The access key used in the prepared request. - secret_key: The secret key used in the prepared request. - - Attributes: - prepared_request: A request to make which would be successful. - successful_headers_result_code: The expected result code if the - example path is requested with the method. - successful_headers_status_code: The expected status code if the - example path is requested with the method. - auth_header_content_type: The content type to use for the - `Authorization` header. - access_key: The access key used in the prepared request. - secret_key: The secret key used in the prepared request. - """ - self.prepared_request = prepared_request - self.successful_headers_status_code = successful_headers_status_code - self.successful_headers_result_code = successful_headers_result_code - headers = prepared_request.headers - content_type = headers.get('Content-Type', '') - content_type = content_type.split(';')[0] - assert isinstance(content_type, str) - self.auth_header_content_type: str = content_type - self.access_key = access_key - self.secret_key = secret_key + @beartype + def send(self) -> Response: + """Send the request.""" + return _send_request( + method=self.method, + url=urljoin(base=self.base_url, url=self.path_url), + headers=self.headers, + data=self.data, + ) + + @property + def auth_header_content_type(self) -> str: + """The content type to use for the `Authorization` header.""" + full_content_type = dict(self.headers).get("Content-Type", "") + return full_content_type.split(sep=";")[0] +@dataclass(frozen=True, kw_only=True) +class ModelTargetEndpoint: + """Details of Model Target Web API endpoints to be called in tests. + + Args: + base_url: The base URL of the endpoint. + path_url: The path of the endpoint. + method: The HTTP method of the endpoint. + headers: Headers to send to the endpoint. These do not include an + ``Authorization`` header; tests add a valid or invalid bearer + token themselves. + data: The body to send to the endpoint. + takes_json_body: Whether the endpoint reads a JSON request body. + + Attributes: + base_url: The base URL of the endpoint. + path_url: The path of the endpoint. + method: The HTTP method of the endpoint. + headers: Headers to send to the endpoint. These do not include an + ``Authorization`` header; tests add a valid or invalid bearer + token themselves. + data: The body to send to the endpoint. + takes_json_body: Whether the endpoint reads a JSON request body. + """ + + base_url: str + path_url: str + method: str + headers: Mapping[str, str] + data: bytes + takes_json_body: bool + + @beartype + def send(self) -> Response: + """Send the request.""" + return _send_request( + method=self.method, + url=urljoin(base=self.base_url, url=self.path_url), + headers=self.headers, + data=self.data, + ) + + +@beartype def make_image_file( + *, file_format: str, - color_space: str, + color_space: Literal["RGB", "CMYK"], width: int, height: int, ) -> io.BytesIO: - """ - Return an image file in the given format and color space. + """Return an image file in the given format and color space. The image file is filled with randomly colored pixels. Args: file_format: See https://pillow.readthedocs.io/en/3.1.x/handbook/image-file-formats.html - color_space: One of "L", "RGB", or "CMYK". "L" means greyscale. + color_space: One of "RGB", or "CMYK". width: The width, in pixels of the image. height: The width, in pixels of the image. @@ -86,15 +166,56 @@ def make_image_file( An image file in the given format and color space. """ image_buffer = io.BytesIO() - image = Image.new(color_space, (width, height)) - # If this assertion ever fails, see - # https://github.com/VWS-Python/vws-test-fixtures for what to do. - assert color_space != 'L' - reds = random.choices(population=range(0, 255), k=width * height) - greens = random.choices(population=range(0, 255), k=width * height) - blues = random.choices(population=range(0, 255), k=width * height) - pixels = list(zip(reds, greens, blues)) - image.putdata(pixels) - image.save(image_buffer, file_format) + image = Image.new(mode=color_space, size=(width, height)) + for row_index in range(height): + for column_index in range(width): + red = secrets.choice(seq=range(255)) + green = secrets.choice(seq=range(255)) + blue = secrets.choice(seq=range(255)) + image.putpixel( + xy=(column_index, row_index), + value=(red, green, blue), + ) + + image.save(fp=image_buffer, format=file_format) + image_buffer.seek(0) + return image_buffer + + +@beartype +def make_single_color_image_file(*, width: int, height: int) -> io.BytesIO: + """Return a greyscale PNG file of one color. + + A single color image compresses to a tiny file whatever its dimensions, so + this is a way to make an image with many pixels but a small file size. + + Args: + width: The width, in pixels, of the image. + height: The height, in pixels, of the image. + + Returns: + A greyscale PNG file of one color. + """ + image_buffer = io.BytesIO() + image = Image.new(mode="L", size=(width, height)) + image.save(fp=image_buffer, format="PNG") image_buffer.seek(0) return image_buffer + + +@beartype +def make_decompression_bomb_image_file() -> io.BytesIO: + """Return a PNG file which is tiny on disk but huge when decoded. + + The dimensions are within the maximum width and height accepted by the + Query API, and the file is well within the maximum file size, but the + pixel count is above the point at which Pillow refuses to open an image. + + Returns: + A PNG file which is a decompression bomb. + """ + # Pillow raises ``Image.DecompressionBombError`` for images with more than + # twice ``Image.MAX_IMAGE_PIXELS`` pixels, which is 178956970 pixels by + # default. + width = height = 15_000 + return make_single_color_image_file(width=width, height=height) diff --git a/tests/mock_vws/utils/assertions.py b/tests/mock_vws/utils/assertions.py index 8f9cfb162..f0d132329 100644 --- a/tests/mock_vws/utils/assertions.py +++ b/tests/mock_vws/utils/assertions.py @@ -1,6 +1,4 @@ -""" -Assertion helpers. -""" +"""Assertion helpers.""" import copy import datetime @@ -8,21 +6,22 @@ import json from http import HTTPStatus from string import hexdigits -from typing import Optional +from zoneinfo import ZoneInfo -from backports.zoneinfo import ZoneInfo -from requests import Response +from beartype import beartype +from vws.response import Response from mock_vws._constants import ResultCodes +@beartype def assert_vws_failure( + *, response: Response, status_code: int, result_code: ResultCodes, ) -> None: - """ - Assert that a VWS failure response is as expected. + """Assert that a VWS failure response is as expected. Args: response: The response returned by a request to VWS. @@ -33,7 +32,10 @@ def assert_vws_failure( AssertionError: The response is not in the expected VWS error format for the given codes. """ - assert response.json().keys() == {'transaction_id', 'result_code'} + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } assert_vws_response( response=response, status_code=status_code, @@ -41,10 +43,13 @@ def assert_vws_failure( ) -def assert_valid_date_header(response: Response) -> None: - """ - Assert that a response includes a `Date` header which is within two minutes - of "now". +@beartype +def assert_valid_date_header( + *, + response: Response, +) -> None: + """Assert that a response includes a `Date` header which is within two + minutes of "now". Args: response: The response returned by a request to a Vuforia service. @@ -53,11 +58,11 @@ def assert_valid_date_header(response: Response) -> None: AssertionError: The response does not include a `Date` header which is within one minute of "now". """ - date_response = response.headers['Date'] - date_from_response = email.utils.parsedate(date_response) + date_response = response.headers["Date"] + date_from_response = email.utils.parsedate(data=date_response) assert date_from_response is not None year, month, day, hour, minute, second, _, _, _ = date_from_response - gmt = ZoneInfo('GMT') + gmt = ZoneInfo(key="GMT") datetime_from_response = datetime.datetime( year=year, month=month, @@ -72,9 +77,12 @@ def assert_valid_date_header(response: Response) -> None: assert time_difference < datetime.timedelta(minutes=2) -def assert_valid_transaction_id(response: Response) -> None: - """ - Assert that a response includes a valid transaction ID. +@beartype +def assert_valid_transaction_id( + *, + response: Response, +) -> None: + """Assert that a response includes a valid transaction ID. Args: response: The response returned by a request to a Vuforia service. @@ -82,14 +90,15 @@ def assert_valid_transaction_id(response: Response) -> None: Raises: AssertionError: The response does not include a valid transaction ID. """ - transaction_id = response.json()['transaction_id'] - assert len(transaction_id) == 32 + transaction_id = json.loads(s=response.text)["transaction_id"] + expected_transaction_id_length = 32 + assert len(transaction_id) == expected_transaction_id_length assert all(char in hexdigits for char in transaction_id) -def assert_json_separators(response: Response) -> None: - """ - Assert that a JSON response is formatted correctly. +@beartype +def assert_json_separators(*, response: Response) -> None: + """Assert that a JSON response is formatted correctly. Args: response: The response returned by a request to a Vuforia service. @@ -98,20 +107,21 @@ def assert_json_separators(response: Response) -> None: AssertionError: The response JSON is not formatted correctly. """ assert response.text == json.dumps( - obj=response.json(), - separators=(',', ':'), + obj=json.loads(s=response.text), + separators=(",", ":"), ) +@beartype def assert_vws_response( + *, response: Response, status_code: int, result_code: ResultCodes, ) -> None: - """ - Assert that a VWS response is as expected, at least in part. + """Assert that a VWS response is as expected, at least in part. - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Interperete-VWS-API-Result-Codes + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes implies that the expected status code can be worked out from the result code. However, this is not the case as the real results differ from the documentation. @@ -128,28 +138,37 @@ def assert_vws_response( given codes. """ assert response.status_code == status_code - response_result_code = response.json()['result_code'] + response_result_code = json.loads(s=response.text)["result_code"] assert response_result_code == result_code.value response_header_keys = { - 'Connection', - 'Content-Length', - 'Content-Type', - 'Date', - 'Server', + "connection", + "content-length", + "content-type", + "date", + "server", + "strict-transport-security", + "x-aws-region", + "x-content-type-options", + "x-envoy-upstream-service-time", } - assert response.headers.keys() == response_header_keys - assert response.headers['Connection'] == 'keep-alive' - assert response.headers['Content-Length'] == str(len(response.text)) - assert response.headers['Content-Type'] == 'application/json' - assert response.headers['Server'] == 'nginx' + assert {str.lower(key) for key in response.headers} == response_header_keys + assert response.headers["Content-Length"] == str(object=len(response.text)) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["server"] == "envoy" + assert response.headers["x-content-type-options"] == "nosniff" + assert "-" in response.headers["x-aws-region"] + assert response.headers["strict-transport-security"] == "max-age=31536000" + assert int(response.headers["x-envoy-upstream-service-time"]) > 1 + assert response.headers["Connection"] == "keep-alive" assert_json_separators(response=response) assert_valid_transaction_id(response=response) assert_valid_date_header(response=response) -def assert_query_success(response: Response) -> None: - """ - Assert that the given response is a success response for performing an +@beartype +def assert_query_success(*, response: Response) -> None: + """Assert that the given response is a success response for performing + an image recognition query. Raises: @@ -157,36 +176,41 @@ def assert_query_success(response: Response) -> None: for performing an image recognition query. """ assert response.status_code == HTTPStatus.OK - assert response.json().keys() == {'result_code', 'results', 'query_id'} + assert json.loads(s=response.text).keys() == { + "result_code", + "results", + "query_id", + } - query_id = response.json()['query_id'] - assert len(query_id) == 32 + query_id = json.loads(s=response.text)["query_id"] + expected_query_id_length = 32 + assert len(query_id) == expected_query_id_length assert all(char in hexdigits for char in query_id) - assert response.json()['result_code'] == 'Success' + assert json.loads(s=response.text)["result_code"] == "Success" assert_valid_date_header(response=response) - copied_response_headers = dict(copy.deepcopy(response.headers)) - copied_response_headers.pop('Date') + copied_response_headers = response.headers.copy() + copied_response_headers.pop("Date") # In the mock, all responses have the ``Content-Encoding`` ``gzip``. # In the real Vuforia, some do and some do not. # We are not sure why. - content_encoding = copied_response_headers.pop('Content-Encoding', None) - assert content_encoding in (None, 'gzip') + content_encoding = copied_response_headers.pop("Content-Encoding", None) + assert content_encoding in {None, "gzip"} expected_response_header_not_chunked = { - 'Connection': 'keep-alive', - 'Content-Length': str(response.raw.tell()), - 'Content-Type': 'application/json', - 'Server': 'nginx', + "Connection": "keep-alive", + "Content-Length": str(object=response.tell_position), + "Content-Type": "application/json", + "Server": "nginx", } # The mock does not send chunked responses. expected_response_header_chunked = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'transfer-encoding': 'chunked', + "Connection": "keep-alive", + "Content-Type": "application/json", + "Server": "nginx", + "transfer-encoding": "chunked", } assert copied_response_headers in ( @@ -195,18 +219,25 @@ def assert_query_success(response: Response) -> None: ) +@beartype def assert_vwq_failure( + *, response: Response, status_code: int, - content_type: Optional[str], + content_type: str | None, + cache_control: str | None, + www_authenticate: str | None, + connection: str, ) -> None: - """ - Assert that a VWQ failure response is as expected. + """Assert that a VWQ failure response is as expected. Args: response: The response returned by a request to VWQ. content_type: The expected Content-Type header. - status_code: The expected status code of the response. + status_code: The expected status code. + cache_control: The expected Cache-Control header. + www_authenticate: The expected WWW-Authenticate header. + connection: The expected Connection header. Raises: AssertionError: The response is not in the expected VWQ error format @@ -214,27 +245,43 @@ def assert_vwq_failure( """ assert response.status_code == status_code response_header_keys = { - 'Connection', - 'Content-Length', - 'Date', - 'Server', + "Connection", + "Content-Length", + "Date", + "Server", } - if status_code == HTTPStatus.INTERNAL_SERVER_ERROR: - response_header_keys.add('Cache-Control') - cache_control = 'must-revalidate,no-cache,no-store' - assert response.headers['Cache-Control'] == cache_control + if cache_control is not None: + response_header_keys.add("Cache-Control") + assert response.headers["Cache-Control"] == cache_control if content_type is not None: - response_header_keys.add('Content-Type') - assert response.headers['Content-Type'] == content_type - - if status_code == HTTPStatus.UNAUTHORIZED: - response_header_keys.add('WWW-Authenticate') - assert response.headers['WWW-Authenticate'] == 'VWS' - - assert response.headers.keys() == response_header_keys - assert response.headers['Connection'] == 'keep-alive' - assert response.headers['Content-Length'] == str(len(response.text)) + response_header_keys.add("Content-Type") + assert response.headers["Content-Type"] == content_type + + if www_authenticate is not None: + response_header_keys.add("WWW-Authenticate") + assert response.headers["WWW-Authenticate"] == www_authenticate + + # Sometimes the "transfer-encoding" is given. + # It is not given by the mock. + response_header_keys_chunked = copy.copy(x=response_header_keys) + response_header_keys_chunked.remove("Content-Length") + response_header_keys_chunked.add("transfer-encoding") + + assert response.headers.keys() in ( + response_header_keys, + response_header_keys_chunked, + ) + assert response.headers.get("transfer-encoding", "chunked") == "chunked" + assert response.headers["Connection"] == connection + if "Content-Length" in response.headers: # pragma: no cover + assert response.headers["Content-Length"] == str( + object=len(response.text) + ) + # In some tests we see that sometimes there is no Content-Length header + # here. + else: # pragma: no cover + pass assert_valid_date_header(response=response) - assert response.headers['Server'] == 'nginx' + assert response.headers["Server"] == "nginx" diff --git a/tests/mock_vws/utils/retries.py b/tests/mock_vws/utils/retries.py new file mode 100644 index 000000000..963b7f7a1 --- /dev/null +++ b/tests/mock_vws/utils/retries.py @@ -0,0 +1,24 @@ +"""Helpers for retrying requests to VWS.""" + +from requests.exceptions import Timeout as RequestsTimeout +from tenacity import retry +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_attempt +from tenacity.wait import wait_fixed +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import ( + TooManyRequestsError, +) + +TRANSIENT_VWS_EXCEPTIONS = (TooManyRequestsError, ServerError, RequestsTimeout) +TRANSIENT_VWS_RETRY_ATTEMPTS = 10 + +# We rely on pytest-retry for exceptions *during* tests. +# We use tenacity for exceptions *before* tests. +# See https://github.com/str0zzapreti/pytest-retry/issues/33. +RETRY_ON_TRANSIENT_VWS_FAILURE = retry( + retry=retry_if_exception_type(exception_types=TRANSIENT_VWS_EXCEPTIONS), + stop=stop_after_attempt(max_attempt_number=TRANSIENT_VWS_RETRY_ATTEMPTS), + wait=wait_fixed(wait=10), + reraise=True, +) diff --git a/tests/mock_vws/utils/too_many_requests.py b/tests/mock_vws/utils/too_many_requests.py new file mode 100644 index 000000000..c35cb54d1 --- /dev/null +++ b/tests/mock_vws/utils/too_many_requests.py @@ -0,0 +1,35 @@ +"""Helpers for handling too many requests errors.""" + +from http import HTTPStatus + +from beartype import beartype +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.response import Response + + +@beartype +def handle_server_errors(*, response: Response) -> None: + """Raise errors if the response is a 429 or 5xx. This is useful for + retrying tests based on the exceptions they raise. + + Raises: + vws.exceptions.vws_exceptions.TooManyRequestsError: The response is a + 429. + vws.exceptions.custom_exceptions.ServerError: The response is a 5xx. + """ + # We do not cover this because in some test runs we will not hit the + # error. + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + # We raise this here to prompt a retry at a higher level. + raise TooManyRequestsError(response=response) + + # We do not cover this because in some test runs we will not hit the + # error. + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) diff --git a/tests/mock_vws/utils/usage_test_helpers.py b/tests/mock_vws/utils/usage_test_helpers.py new file mode 100644 index 000000000..708b1b168 --- /dev/null +++ b/tests/mock_vws/utils/usage_test_helpers.py @@ -0,0 +1,40 @@ +"""Helpers for testing the usage of the mocks.""" + +import datetime +import io + +from beartype import beartype +from vws import VWS +from vws.reports import TargetStatuses + +from mock_vws.database import CloudDatabase + + +@beartype +def processing_time_seconds( + *, + vuforia_database: CloudDatabase, + image: io.BytesIO, +) -> float: + """Return the time taken to process a target in the database.""" + vws_client = VWS( + server_access_key=vuforia_database.server_access_key, + server_secret_key=vuforia_database.server_secret_key, + ) + target_id = vws_client.add_target( + name="example", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + start_time = datetime.datetime.now(tz=datetime.UTC) + + while ( + vws_client.get_target_record(target_id=target_id).status + == TargetStatuses.PROCESSING + ): + pass + + processing_time = datetime.datetime.now(tz=datetime.UTC) - start_time + return processing_time.total_seconds() diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..ce3608d15 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2998 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "actionlint-py" +version = "1.7.12.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/0b/3f29683dfbe94208fb5c3806806a6ef419972892e25c3c4f95198f68c978/actionlint_py-1.7.12.24.tar.gz", hash = "sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e", size = 12071, upload-time = "2026-03-31T06:21:35.015Z" } + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "apeye" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apeye-core" }, + { name = "domdf-python-tools" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/6b/cc65e31843d7bfda8313a9dc0c77a21e8580b782adca53c7cb3e511fe023/apeye-1.4.1.tar.gz", hash = "sha256:14ea542fad689e3bfdbda2189a354a4908e90aee4bf84c15ab75d68453d76a36", size = 99219, upload-time = "2023-08-14T15:32:41.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/7b/2d63664777b3e831ac1b1d8df5bbf0b7c8bee48e57115896080890527b1b/apeye-1.4.1-py3-none-any.whl", hash = "sha256:44e58a9104ec189bf42e76b3a7fe91e2b2879d96d48e9a77e5e32ff699c9204e", size = 107989, upload-time = "2023-08-14T15:32:40.064Z" }, +] + +[[package]] +name = "apeye-core" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "domdf-python-tools" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/4c/4f108cfd06923bd897bf992a6ecb6fb122646ee7af94d7f9a64abd071d4c/apeye_core-1.1.5.tar.gz", hash = "sha256:5de72ed3d00cc9b20fea55e54b7ab8f5ef8500eb33a5368bc162a5585e238a55", size = 96511, upload-time = "2024-01-30T17:45:48.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/9f/fa9971d2a0c6fef64c87ba362a493a4f230eff4ea8dfb9f4c7cbdf71892e/apeye_core-1.1.5-py3-none-any.whl", hash = "sha256:dc27a93f8c9e246b3b238c5ea51edf6115ab2618ef029b9f2d9a190ec8228fbf", size = 99286, upload-time = "2024-01-30T17:45:46.764Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "autodocsumm" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/f28dea12fae1d1ad1e706f5cf6d16e8d735f305ebee86fd9390e099bd27d/autodocsumm-0.2.15.tar.gz", hash = "sha256:eaf431e7a5a39e41a215311173c8b95e83859059df1ccf3b79c64bf3d5582b3c", size = 46674, upload-time = "2026-03-26T20:44:07.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/3d/4357a0f685c0a2ae7132ac91905bec565e64f9ba63b079f7ec5da46e3597/autodocsumm-0.2.15-py3-none-any.whl", hash = "sha256:dbe6fabcaeae4540748ea9b3443eb76c2692e063d44f004f67c424610a5aca9a", size = 14852, upload-time = "2026-03-26T20:44:05.273Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "check-manifest" +version = "0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "build" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/e3/8ce797dfdf12d447683490107c4dcd97bb2535d7a2b031bf3f3e4c441c3d/check_manifest-0.51.tar.gz", hash = "sha256:9801c7637675755a563f33e3c48ee59a59b37a7677297c05c910c16c5b9b6d67", size = 36302, upload-time = "2025-10-15T11:15:48.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c1/df01ef6ba1c8a2bfc201be45d0889b06f165008b240e8d1657aa665421a8/check_manifest-0.51-py3-none-any.whl", hash = "sha256:f5f35ed561012fc2115bb070e42a748ac2e034cf8904ab4dfaae893859085ca4", size = 20500, upload-time = "2025-10-15T11:15:46.058Z" }, +] + +[[package]] +name = "check-wheel-contents" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wheel-filename" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/37/2b9e0d38c2f668791ffe8b97711185c364d91c027e47f189c371c2348d18/check_wheel_contents-0.6.3.tar.gz", hash = "sha256:10e6939e2fe4e6ce1edf2ff6ec6157808677e80782e78021ae139dd88473a442", size = 586023, upload-time = "2025-08-02T14:01:45.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl", hash = "sha256:5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874", size = 27541, upload-time = "2025-08-02T14:01:43.968Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "click-compose" +version = "2025.10.27.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/9f/7b380e5318643348e256ec31df1362b74dfa12733f76b1a97e1171ba74fe/click_compose-2025.10.27.3.tar.gz", hash = "sha256:6d3326a13b690ac7a0f0e99de785aa78ea81d130ba02d609e6367a7af23477a5", size = 18056, upload-time = "2025-10-27T11:49:45.228Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/3a/411c2ad30f87b2e874a4a4d1578dc9fe11a6ea8f139b4e1f7ff291a934ca/click_compose-2025.10.27.3-py2.py3-none-any.whl", hash = "sha256:6821fb769067e76d2b2e9c5d4d5e8d974002137322ab71cde65b10bf9c025834", size = 4731, upload-time = "2025-10-27T11:49:43.857Z" }, +] + +[[package]] +name = "cloup" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/ca/cf02e965cfeb70d65c61fd3abb8022aaf5111a0de71b3c73a6ec2113aa25/cloup-3.1.0.tar.gz", hash = "sha256:637c1e628fe98f3f20a5e44da591a72b42bf54d7d4527190bf39ed5f64af7585", size = 230167, upload-time = "2026-05-26T02:48:18.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/07/644976263e2d346935b35305908bf89eb660ced7fbc7292b096f38c0a80d/cloup-3.1.0-py2.py3-none-any.whl", hash = "sha256:f4cfbdcdc96d30bcbd8c75eaffb0651e9c57180f2aee4b300cf0dd168b8415ed", size = 55028, upload-time = "2026-05-26T02:48:16.711Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[[package]] +name = "deptry" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "packaging" }, + { name = "requirements-parser" }, + { name = "tomli", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/b2/50ccc99362ae7757342978b7ecb3b98e47fade721fd617d74db1948ec3a1/deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d", size = 509748, upload-time = "2026-03-18T23:22:18.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/1d/b538dc635e873b25360d761cfe1fa0ccd7d6c69b698047e552f33401e60d/deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8", size = 1850319, upload-time = "2026-03-18T23:22:15.65Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a9/511477a8f0ae4f6021d68a80bdca77e7ffb0722008dc24ee5d9ef49f5c88/deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d", size = 1759259, upload-time = "2026-03-18T23:22:30.853Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4b/c9f0bdda410912a6df79a789cb118fa29acae02a397794ead3c84adcda5c/deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091", size = 1872012, upload-time = "2026-03-18T23:22:19.145Z" }, + { url = "https://files.pythonhosted.org/packages/72/9c/6f6f9125bac74b5d5d2af89536cbdb3fa159b6466aa097b74e7e85e8e030/deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f", size = 1926575, upload-time = "2026-03-18T23:22:11.269Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/2a5e705a7f898295966ade67bd1223e2af96da433e25b39f6b9483ba2c7b/deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3", size = 2050816, upload-time = "2026-03-18T23:22:27.439Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/50f189a894e1f3bf21266299112c8a06cb731838976e1b9a9cadd0b4a86e/deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7", size = 2145416, upload-time = "2026-03-18T23:22:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/3f82f7a06217778282bc4456af1b4ffb3bc4b2c8e7891d00e8323f9ad0b8/deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99", size = 1718489, upload-time = "2026-03-18T23:22:28.589Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7f/cd6b3ac8cf95f2f1c5c7a74ff6452e9098af89a9b56607381f677880641e/deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12", size = 1647020, upload-time = "2026-03-18T23:22:23.311Z" }, +] + +[[package]] +name = "dict2css" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "domdf-python-tools" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/ae/242596e550f79aa85ab6b5310caadd0b592063dc0c20c397d25707981f65/dict2css-0.6.0.tar.gz", hash = "sha256:143e55cb71c98a88c79f2c41e08a5fa4d875659275756f794e31ccd69936ce88", size = 9268, upload-time = "2026-05-21T08:34:29.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/68/0fbc6124cdd4f5a92599d18345bd24c67977988d0bb277f3ea284321d836/dict2css-0.6.0-py3-none-any.whl", hash = "sha256:5251f1df1c78ffdf09313657a7f88add0ad219127d9aeb18fb343b052d6bfbbe", size = 11874, upload-time = "2026-05-21T08:34:28.548Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "dirty-equals" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/1d/c5913ac9d6615515a00f4bdc71356d302437cb74ff2e9aaccd3c14493b78/dirty_equals-0.11.tar.gz", hash = "sha256:f4ac74ee88f2d11e2fa0f65eb30ee4f07105c5f86f4dc92b09eb1138775027c3", size = 128067, upload-time = "2025-11-17T01:51:24.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051, upload-time = "2025-11-17T01:51:22.849Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "doc8" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "restructuredtext-lint" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/91/88bb55225046a2ee9c2243d47346c78d2ed861c769168f451568625ad670/doc8-2.0.0.tar.gz", hash = "sha256:1267ad32758971fbcf991442417a3935c7bc9e52550e73622e0e56ba55ea1d40", size = 28436, upload-time = "2025-06-13T13:08:53.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e9/90b7d243364d3dce38c8c2a1b8c103d7a8d1383c2b24c735fae0eee038dd/doc8-2.0.0-py3-none-any.whl", hash = "sha256:9862710027f793c25f9b1899150660e4bf1d4c9a6738742e71f32011e2e3f590", size = 25861, upload-time = "2025-06-13T13:08:51.839Z" }, +] + +[[package]] +name = "doccmd" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "charset-normalizer" }, + { name = "click" }, + { name = "click-compose" }, + { name = "cloup" }, + { name = "dulwich" }, + { name = "pygments" }, + { name = "sybil" }, + { name = "sybil-extras" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/1a1c30b66d9dd37d1d12e248943a8723da79b5c7d945bba5c0e469252433/doccmd-2026.7.19.tar.gz", hash = "sha256:1bb47f9ba5a3aaa907a5b52cd42f62d718689eccef68b94740127f7b758ce573", size = 196169, upload-time = "2026-07-19T13:56:40.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/ef/c97a0d16c9c2f0870811e9b03548f2aa3fc3e1e39fe80b768fa8458d6d8a/doccmd-2026.7.19-py3-none-any.whl", hash = "sha256:32d1bd7dfd87ebe28b7cf7637413012b2909514e7da3400de5dcc1cb18da1235", size = 24701, upload-time = "2026-07-19T13:56:39.117Z" }, +] + +[[package]] +name = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "dom-toml" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "domdf-python-tools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/91/cdad3f64c5bbe7650fc617f2f756b28827dd5f30b9f7b78597ce3e96fcd2/dom_toml-2.3.0.tar.gz", hash = "sha256:04d1138a7588119ec37ffe59e6474739a7ce7fcfcdf76555a064878ad82e3ae0", size = 13041, upload-time = "2026-01-22T23:12:06.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/cb/20465053f0f4854c261c038afce2703d71cc71d58035a53404128b1abfe7/dom_toml-2.3.0-py3-none-any.whl", hash = "sha256:bc2f985db6964de47b113783a6b18f1688693b2a47dec3c7451d3531ccab7029", size = 17505, upload-time = "2026-01-22T23:12:05.328Z" }, +] + +[[package]] +name = "domdf-python-tools" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "natsort" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/8b/ab2d8a292bba8fe3135cacc8bfd3576710a14b8f2d0a8cde19130d5c9d21/domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298", size = 100458, upload-time = "2025-02-12T17:34:05.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/11/208f72084084d3f6a2ed5ebfdfc846692c3f7ad6dce65e400194924f7eed/domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36", size = 126860, upload-time = "2025-02-12T17:34:04.093Z" }, +] + +[[package]] +name = "dulwich" +version = "1.2.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/73/e0ac42b16e180189e8426af41b1f29b079096088e1253d03322259945911/dulwich-1.2.12.tar.gz", hash = "sha256:1278d8ddb0a92fa4bc9f2e9b14edf0a2e140248bccc4c7c9752a1390e2ab4c64", size = 1323805, upload-time = "2026-07-19T11:16:39.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/64/5ddb773c16b77eee8afcc5904a9a9b4a10a86a5ae55e50cad850e494c465/dulwich-1.2.12-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:cdf4348b581d3779a7197714148a0ad79c5c8ca8cb9be910a92f09ed3489e179", size = 1512182, upload-time = "2026-07-19T11:16:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/91/01/0560297b39903572f0997b7d9906061e66bc0f31025bb365fcfd44b49199/dulwich-1.2.12-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7c89af3e8217878abafcfc94c96e88845577a31f411c70eb21c84172d921a689", size = 1549956, upload-time = "2026-07-19T11:16:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/f4/59/a132009948f46f0350bf717ffdf534aacea10700e50cb20d7009fb1cd80f/dulwich-1.2.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d40db45756bbd449ce148cc961cffe31dfdade9465c24e2754b6e240ba004ffb", size = 1378120, upload-time = "2026-07-19T11:16:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ee/68/d007b61d316903c8cff3a42f05c828f48264781ee49145def88f5601b262/dulwich-1.2.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e68f58b33b357c77a7254ce305b8d7301279d7301f590566cf4807c7da5f4a2", size = 1361095, upload-time = "2026-07-19T11:16:17.227Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c7/9e7d8c20059cca725ef6e312bcf880f9ba5b185fdb60aafc6e8f5cd3f8de/dulwich-1.2.12-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d6b6cc65237777e2cb6c9cab0ce0265ef9d887b63d46cf888499e8b5664fb89f", size = 1485902, upload-time = "2026-07-19T11:16:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/75/e5/6385536ab16dad76e4df7f9946cded898a25462987f7cb7147c153a48910/dulwich-1.2.12-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6005a37dda836208079788928876091efeb374e3d6f6df2ed799e1303f5828c5", size = 1514655, upload-time = "2026-07-19T11:16:20.627Z" }, + { url = "https://files.pythonhosted.org/packages/2b/50/23e8bb5bcd5da753ddf3de86d87c414421be875cb502ac544c65606538cd/dulwich-1.2.12-cp314-cp314-win32.whl", hash = "sha256:d7f1ff233081ab644f35e90a0ef169f8d9ee46a72820a3fbf8476f9665e79800", size = 1097847, upload-time = "2026-07-19T11:16:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0a/d9c86cac95bad48292efa47195e9ac6e3137198c4e20ed7f354418bc375b/dulwich-1.2.12-cp314-cp314-win_amd64.whl", hash = "sha256:b41a430bdc2bdab159b43205411e8cd19ac1ea24d238b69ede4d3808c4ec03a7", size = 1111503, upload-time = "2026-07-19T11:16:24.704Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/035ec46c4abb89aa445e337679dba72e8ab54c4dde37b782ea959bc19c18/dulwich-1.2.12-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:370bc3a9cbc4be41f0a215572c178ba922d9a8bdfd789ff204a94383d1e5b12f", size = 1375358, upload-time = "2026-07-19T11:16:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c9/3e86036ca9214f66809c54149ed21462cfcb4c9db8c7a4a15657c854e4dc/dulwich-1.2.12-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b3f05a0f4d090c06308da294ece0356b40a6136f3c211bd13a3cf45d68b2299", size = 1402368, upload-time = "2026-07-19T11:16:29.212Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/c8766b44ffdbd50fe9e1a4c904489bac6adeab9f20a400c09b1dc6dfe121/dulwich-1.2.12-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cb24269e682ccf2ecafce582409534ebb93ec2d109e660d0f43791f91dd1580e", size = 1439417, upload-time = "2026-07-19T11:16:31.004Z" }, + { url = "https://files.pythonhosted.org/packages/fb/85/adcced39b14408409db58f1a31d2d9837e92d940297256e6167be26e049a/dulwich-1.2.12-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4d832dc29697c4a6d3b18ce7c46e015035a62e18a9c546c85636024210b07c82", size = 1468567, upload-time = "2026-07-19T11:16:32.629Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1c/6d64309c1fc04829c9c92c7bf5b820713e0b94c3a70659af4728b4163c1b/dulwich-1.2.12-cp314-cp314t-win32.whl", hash = "sha256:3a6159a32220aa4c3d60a0ce76ce20cefa22c610f83381aa288317cb81999fac", size = 1050649, upload-time = "2026-07-19T11:16:34.348Z" }, + { url = "https://files.pythonhosted.org/packages/ee/66/ec2605e47123025479f3e1c0f6d21199add60d41889dcce605bb4e4dc1ad/dulwich-1.2.12-cp314-cp314t-win_amd64.whl", hash = "sha256:b4301446d72fcdbe9703065e88a82f4c1f176d87514bd2444a8234047e1f42a6", size = 1067770, upload-time = "2026-07-19T11:16:35.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/6c1a89af18f160a9d7311fddbd62068e347c2bf6cfada8530ebfd4e75b8b/dulwich-1.2.12-py3-none-any.whl", hash = "sha256:713de88063b80ab37d707e7aff17e403efb236156e09c34c149ada48d48b6e96", size = 715939, upload-time = "2026-07-19T11:16:37.722Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + +[[package]] +name = "furo" +version = "2025.12.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpretty" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/19/850b7ed736319d0c4088581f4fc34f707ef14461947284026664641e16d4/httpretty-1.1.4.tar.gz", hash = "sha256:20de0e5dd5a18292d36d928cc3d6e52f8b2ac73daec40d41eb62dee154933b68", size = 442389, upload-time = "2021-08-16T19:35:31.4Z" } + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "interrogate" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "colorama" }, + { name = "py" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "maison" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "loguru" }, + { name = "platformdirs" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/45/7cb1d08b6b5674c381b6e0232d35f417a1eba8bb66cdc18edff2b9c80b68/maison-2.0.2.tar.gz", hash = "sha256:476f2bf414a20f5abf5a9856bd4db78b5a33c695654a0fc49c3c4abed78c2efc", size = 16012, upload-time = "2025-10-09T07:52:33.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/8f/3f0895a18cad5afd61c16ac38d35a2466f0cac8ae5c28f1a67f7a81bcdec/maison-2.0.2-py3-none-any.whl", hash = "sha256:835de804aa8063795b48c4fe2b4918106cfda4e5df515e8784ec9fa64cd28191", size = 13464, upload-time = "2025-10-09T07:52:31.987Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[package.optional-dependencies] +faster-cache = [ + { name = "orjson" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "mypy-strict-kwargs" +version = "2026.7.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/ff/c7100891e45af1ce05db4644ea4a6e3f53aa6a019ab9defecd5fde9e7d69/mypy_strict_kwargs-2026.7.19.1.tar.gz", hash = "sha256:735a1956937365daaea84a6a3a556fde7255575e6fabc5336e499bb890abe44f", size = 30368, upload-time = "2026-07-19T11:59:40.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/8a/0f55537fcfe8d358f681059be0ea158df2066d629215cd9090eae4937e17/mypy_strict_kwargs-2026.7.19.1-py3-none-any.whl", hash = "sha256:80c56a7792bb4f0880f7686a8f576022514b219ff8c7c1a8c6e5096c0d64cae2", size = 14064, upload-time = "2026-07-19T11:59:39.009Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + +[[package]] +name = "no-defaults" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/dd/37d0ae30b5d74d039d9e9c1b0e7dd5ebee509fe848314825505f2798a444/no_defaults-2.1.0.tar.gz", hash = "sha256:d98d78c7dace794bed068fbbada2ee7e94705141aa17c2a8d7047bacb3da2562", size = 96084, upload-time = "2026-08-07T12:35:34.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5e/2efb07ac90e5db3623a6ee6fbacbd3635d4d63c359cfc94ed39cca685649/no_defaults-2.1.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94b884e31e7727f6a476b6dff0b655fd37b65f6f209ddccba917888260050ca0", size = 2141080, upload-time = "2026-08-07T12:35:30.314Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/33eacae5d5b420df33a9b5a716eae4a8c3528a795e6435cbcf7fe00d0041/no_defaults-2.1.0-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:bed135d188635d786cca6dde75982d507f8e2b46fbc17f047c794af2153186d3", size = 2328857, upload-time = "2026-08-07T12:35:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/35/a2/8cdeb0081d019275c86a1e120aa744b13de6ca4260162b6c41215fa0e007/no_defaults-2.1.0-py3-none-win_amd64.whl", hash = "sha256:d14855c701f5e1310e304e3011cbc5836fa19e7494125e6673dd14701fc49b7e", size = 2052526, upload-time = "2026-08-07T12:35:33.435Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "opencv-contrib-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/85/da534b90d99a040fbdccc6eb2c3d1c6c6d0e1f7d54732f4ce7d2e1726b29/opencv_contrib_python_headless-5.0.0.93.tar.gz", hash = "sha256:6a8c34de905f59b038f6ab0475e73fb0a0f345c3582ec3aeaeb1c0912e80cddb", size = 154148527, upload-time = "2026-07-02T06:59:00.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b9/2a3a9e23d7894f816792f93f2e73c017765a29e854ba343cb260da400a7e/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:bf1e2b3c502b4fbe7b06e307bd15c4e7bea7da8895de19c189db01d53062f997", size = 55653233, upload-time = "2026-07-02T05:50:39.358Z" }, + { url = "https://files.pythonhosted.org/packages/63/72/1b7d64b03e54f775035bda1dc363dfde89e9301a9bdf82949b239ddf80ad/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:68eb7b803f68998552a83b5f3c69bfb1c7d6c114dc30f828657bddc7ca68573a", size = 42828135, upload-time = "2026-07-02T05:51:44.374Z" }, + { url = "https://files.pythonhosted.org/packages/61/14/f12a54f7e5e8783adf905bb863ef2257e41d73d44793851d318e35ee8121/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c035e12b6078b3351ad577e90a4ff4ead74155fa5470eb8df8f5b54a8ce7727f", size = 43800290, upload-time = "2026-07-02T06:51:24.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c0/a51fd47d4f82cbc0bb36d082c87f6da25e4fb2a1cdec307a72f063cf3d55/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ff190c4ebfa2839bdcaff1810061bc82f9ce5cf37e28953fb57fa5ed519b9d1", size = 64676895, upload-time = "2026-07-02T06:52:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/11f0704e94ebf92f748c789cf5d430d4459ae3649263c712d81772cc4360/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01331ae1a65a21d81310b5584900f73c3012ab4bdcf6aa26085c1870eea45da8", size = 44443761, upload-time = "2026-07-02T06:52:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/3a803f04a28d0161fd9f5e085595507d7008d760ba95be6afa5760a41bbf/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:63ddadd47e36fbc903d5ac141d5c90bd305a8044188dd4936a73de80007c9e6c", size = 69475842, upload-time = "2026-07-02T06:52:49.523Z" }, + { url = "https://files.pythonhosted.org/packages/30/9f/11dd169d7a037c35b1fec0eb766c07bc62cde6428f2762df233111a43204/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:ca47e569680df6eb7317b54f463bc766832d3d87854988168673bb0c52d6d9d4", size = 44203828, upload-time = "2026-07-02T05:50:20.943Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f3/6d7a7e512d88df0358c09c36cfac1a9050c50df547c64d32dc5f82fade49/opencv_contrib_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:72605e3ae78f66592705b1abe62e60f57dd9050787fdd99711b85a02760d5259", size = 53654741, upload-time = "2026-07-02T05:50:17.153Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/98/0bf930c4f97d0266b58a89e36c015f56232c52b5d2f207215d48cca9e8f7/platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d", size = 32716, upload-time = "2026-08-10T15:48:06.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e2/4e6eee633809c376c024821b91ade709cbfd040ec53939ffbcc292aa7eee/platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4", size = 23361, upload-time = "2026-08-10T15:48:04.855Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polib" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, +] + +[[package]] +name = "prek" +version = "0.4.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/79/19f47eeb4d6092d36f94f47a056e7ae7a421d60220c772e8513483521b63/prek-0.4.13.tar.gz", hash = "sha256:9bf3dce400ef38a281836e4fe6429aa5f1690848be77cd97cb53c93769db4681", size = 533200, upload-time = "2026-08-10T08:54:15.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/12/661dc1c63c322000580dffa8503d28d633cb5d4c3181e662cbb86eded12e/prek-0.4.13-py3-none-linux_armv6l.whl", hash = "sha256:6a313f5f041b2fcbd33bceb6b6e11ee9b8621c8c6ad8ef13787a6d90541b624d", size = 5801508, upload-time = "2026-08-10T08:53:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/86/4a/c50597a45d08b22e5704a5d4d5c03269a581b46cf1f060861f9ba96cdade/prek-0.4.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:40436cd7247d2a2fc036ef07d7efcd828acf1aab9d648a8a3f21475e3ad3f789", size = 6141612, upload-time = "2026-08-10T08:53:53.86Z" }, + { url = "https://files.pythonhosted.org/packages/82/93/abc084bbd76bb6c34efbf7db441392f17b7bfc50a54c4d53d3e37c7ad6e0/prek-0.4.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:019a33b477b7b949fb6dcd6cb33ed494c4a28117e53c370c637ec0aba60211aa", size = 5625418, upload-time = "2026-08-10T08:53:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/a310ff9adcb4b822d935eb55f0f5cdcfe4c18a16610bc5e220a11215dc38/prek-0.4.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5a2851b6e60912e73be1bf2cf61fab5b314add15ba7bcf230f860282bdbaf16b", size = 5942132, upload-time = "2026-08-10T08:53:56.789Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d8/811230ff285000abdc0fb98b56b9ef2d23b2dc530cf2da86fe1665f844ba/prek-0.4.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ba9b94bd3f47b5f94a4ae504c44f9529cb3258ebaf577a5a6070ea0c6a853d6", size = 5710181, upload-time = "2026-08-10T08:53:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/74/84/01f7163cc4267daba6b01cfac726327c6d848d2bb7349bd2fd877d88f744/prek-0.4.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33fc9e0d435cb9e650ec108f840febe7a94fd266c77149860091cead212dc82d", size = 6157705, upload-time = "2026-08-10T08:53:59.586Z" }, + { url = "https://files.pythonhosted.org/packages/30/e3/061a0e9ebd7064edf70bb783afa575d045ac0cc9035c2143b76401ebcaf5/prek-0.4.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91066d8978eab83c111e7c34ee3046a003819d2df15ef7dc2bddeb83dce62bc0", size = 6898016, upload-time = "2026-08-10T08:54:00.927Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/5e8e2b9ff6a30b46bf35ca3b50e4d164c26406d9e7457d5b4633c6e1fbc0/prek-0.4.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85646fcc940f30bd946d63b6bbd1000d946994c88e154c5bdde7773a2c358dcf", size = 6365056, upload-time = "2026-08-10T08:54:02.704Z" }, + { url = "https://files.pythonhosted.org/packages/f9/23/afe543b69fb7f35016645eb4b766191e814cbcfa5545d695a2a7dd9b712e/prek-0.4.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:961859c3ddddb8e10367afcf93742da36fa213eda080f168fc1dcbe33e0b004d", size = 5952174, upload-time = "2026-08-10T08:54:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/9e651ce51aa0b03244277f5e0660cf1b946a270cb62635a8a100389a67a2/prek-0.4.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ccf4fcbfc686ad6b589f2908ed6012a315091c45163e1f4420648b9669651a9e", size = 5761875, upload-time = "2026-08-10T08:54:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/cf/64/e70e18734d93df272476d2f1b30d92c71d41ee7141070f1dd13cfbb2eff8/prek-0.4.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:eb4b7edfe00ca73e58d7e26fb871abddc5854e8d21067a202e43418fb7f98ef2", size = 5685365, upload-time = "2026-08-10T08:54:06.729Z" }, + { url = "https://files.pythonhosted.org/packages/de/78/686fd5d3f12249368a0fdd8f7705e3ba540402a6157ef0b888e48734ff83/prek-0.4.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c29449443f89da1647331742984b7046a55084759ad642373a9cc0a973494339", size = 5999013, upload-time = "2026-08-10T08:54:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/9e/aa/93ffac2460b44f6182dbbb3194db976d18332edcb8208c4a796f8f9955f8/prek-0.4.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:7a04d5ac901819b115ecd0bf79cee091f0034323767abcc4a53626eeb96c3be0", size = 6486759, upload-time = "2026-08-10T08:54:09.775Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/9f12c0d469ea345c249d5b0a027a1f2bf1ca05041d7785c34c455a9b605c/prek-0.4.13-py3-none-win32.whl", hash = "sha256:6d1bdcc1699ae18270f9bac9c4b4d29c6f1512b7a067e17ce5e220c30636f88c", size = 5515046, upload-time = "2026-08-10T08:54:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3c/ef9ec67c560e60525d6a49b48a2a60434906f25ec2efa5e010fe2d42bbfa/prek-0.4.13-py3-none-win_amd64.whl", hash = "sha256:2d8fd796ed7944154fbee6d5a6d2490b9d4f14ce3626b1a0c9ca698455b25d9b", size = 5894683, upload-time = "2026-08-10T08:54:12.88Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/8fe8fdf8154108a552d5578400da44144697ed11e5bd71d5a4980d7e202e/prek-0.4.13-py3-none-win_arm64.whl", hash = "sha256:65d6811b0220444bcdf539e157d7d5cb8edab01a5ed89534c70d73d675266ecb", size = 5650616, upload-time = "2026-08-10T08:54:14.21Z" }, +] + +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pydocstringformatter" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/c9/435887301c667ddcf1ed524ba82ff0998c280077f4987da6f3f72cb43778/pydocstringformatter-1.0.0.tar.gz", hash = "sha256:0c2bc5e200ff118feab96c204b1f69ddb604832e177b26b598939e0e6913dcd0", size = 29543, upload-time = "2026-07-04T16:39:24.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/ee/8acba0b2f928bd046ce06eef9c8f0450a3c25711f17b8362e6ed9b473da7/pydocstringformatter-1.0.0-py3-none-any.whl", hash = "sha256:3f625f91798b14ee7c4fc8e1628c2d040390e32efa76f05ef14838e9bc2d1724", size = 30188, upload-time = "2026-07-04T16:39:23.095Z" }, +] + +[[package]] +name = "pydocstyle" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "snowballstemmer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/d5385ca59fd065e3c6a5fe19f9bc9d5ea7f2509fa8c9c22fb6b2031dd953/pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1", size = 36796, upload-time = "2023-01-17T20:29:19.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ea/99ddefac41971acad68f14114f38261c1f27dac0b3ec529824ebc739bdaa/pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", size = 38038, upload-time = "2023-01-17T20:29:18.094Z" }, +] + +[[package]] +name = "pyenchant" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ad/64925c937e41be75c7067c85757b3d45b148e9111187b37693269f583156/pyenchant-3.3.0.tar.gz", hash = "sha256:825288246b5debc9436f91967650974ef0d5636458502619e322c476f1283891", size = 60696, upload-time = "2025-09-14T16:23:12.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/b0/35926bad6885fb7bc24aa7e1b45e6d86540c6c57ee4abc4fed1ef58d4ec0/pyenchant-3.3.0-py3-none-any.whl", hash = "sha256:3da00b1d01314d85aac733bb997415d7a3e875666dc81735ddcf320aa36b7a70", size = 58363, upload-time = "2025-09-14T16:23:04.297Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/1d7b8ad86c2a841d940df7b965fa727e052b95d539e4c563da685c25d0d2/pyenchant-3.3.0-py3-none-win32.whl", hash = "sha256:1d55e075645a6edbb3c590fb42f9e02b4d455e4affe28a2227d5cb6d4868e626", size = 37787278, upload-time = "2025-09-14T16:23:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ae/5624803b62ecb0a20248f0d28ed3f78c78746a032582a016d4b2890c7899/pyenchant-3.3.0-py3-none-win_amd64.whl", hash = "sha256:04a5bd0e022ebe2e8c6d9e498ec3d650602e264ec5486e9c6a1b7f99c9507c49", size = 37427576, upload-time = "2025-09-14T16:23:09.574Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/92/98dace02f2d11b88160354c53944f77ea7327aa78bce1c75971e7aaa4347/pylint-4.0.7.tar.gz", hash = "sha256:9b2d1d15791c84b77a4fe2aafe8f0d9570717e2dea06d53b19c105cf60275a52", size = 1594770, upload-time = "2026-08-09T19:13:23.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/b0/3a8040e53df6c5c1e04b0e23ed53fdbeb64f333723a334d313fba2f581ce/pylint-4.0.7-py3-none-any.whl", hash = "sha256:be4a3111557a614411ed1fc89347ce4a8e1013a59e1f33d11485227a02e3304d", size = 539710, upload-time = "2026-08-09T19:13:21.228Z" }, +] + +[package.optional-dependencies] +spelling = [ + { name = "pyenchant" }, +] + +[[package]] +name = "pylint-per-file-ignores" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pylint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/bc/6d40a3596f91ef23fca7b89983b78bf4b3686323fd98e44e53ae365b1880/pylint_per_file_ignores-3.2.1.tar.gz", hash = "sha256:0a89f3cdc6fa09244a3f5624ad977ac9b026f0b25b2adb48c97c080da8d858f9", size = 81844, upload-time = "2026-04-03T19:35:37.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/68/2b0cc27b549fd788caae254752910fe7222ac47c71627c60926895fe8960/pylint_per_file_ignores-3.2.1-py3-none-any.whl", hash = "sha256:aaac8b118791e742ccf7baaf42346978f6cd0440a9090d4087fc8ff26e4a31f2", size = 5699, upload-time = "2026-04-03T19:35:36.524Z" }, +] + +[[package]] +name = "pyproject-fmt" +version = "2.27.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/39/65b0c0342498ca594f1f678e9bb955bde32ba75ffabf8dff6c711d703109/pyproject_fmt-2.27.0.tar.gz", hash = "sha256:31f638e1d42a6689922d9c413d410a5f3f56e45c844830763321119e919cd45b", size = 300145, upload-time = "2026-08-03T22:54:59.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/41/2ef4c9e092a4c3e7c70311eb2cfd4125fac742734db708ab5c00cef62483/pyproject_fmt-2.27.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0a780b411258f5ee62b9c622c0c07f55053e64e0866cfede5648b99b7573ec84", size = 5344637, upload-time = "2026-08-03T22:54:07.039Z" }, + { url = "https://files.pythonhosted.org/packages/69/56/4f88cfd3f4e83bd6ba16c0a9b0d9776efbeb4bd504fb661da5d9344790a6/pyproject_fmt-2.27.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:62acfbfe9dc150e542d0764aed71e901f6f2d818e3a9c07ee5176306348db146", size = 5102853, upload-time = "2026-08-03T22:54:08.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/93/84b226f18da04a3ef21308f8a310b859e96c44cbeb88723f0409d70eb411/pyproject_fmt-2.27.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:754f840e915861a594ab402c1c62e09c7a53692e5db57eb544a6731154c674ce", size = 5273151, upload-time = "2026-08-03T22:54:10.75Z" }, + { url = "https://files.pythonhosted.org/packages/7f/79/d6d47dc82e39a759819af2cde51adfefcef9cff32a421245f32b18393ecf/pyproject_fmt-2.27.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:97c803bd973d5c8a37a368a7c6d571ff09817570bc48b23b286ab3c55712c124", size = 5667236, upload-time = "2026-08-03T22:54:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/2e2c1a3a558be20b6fdf4c415fed97e5da32d3232ae90f84091a3b00a486/pyproject_fmt-2.27.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:dcdd93f1c1cab93b79ed6777ef729ac7961f3b1e8a7a0b7ff04b4b826a016b96", size = 5365689, upload-time = "2026-08-03T22:54:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/50/9f/b57e8ad891ea9f5fa6afec3c2d25d7d8cc8b8725cfd91381ff9b08baf8d4/pyproject_fmt-2.27.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d274ec865d4af811b874e85f23cfdc332ab05f2a66c5a6e6d10d3ff689462b5d", size = 5272980, upload-time = "2026-08-03T22:54:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/56/61/914c9c4957b2e944c84adf211d6a8f28554bcc97b553de170a81aca5e96e/pyproject_fmt-2.27.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a3fc9615a0b438c2cf9b7d233a9235b5889fd3cc9f7376e90418bce5f4f2acc8", size = 5842234, upload-time = "2026-08-03T22:54:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/90/f8/f16f7cc82eca680997cb36fb175a3a2a9246b47a1d90d718d59baf0f111b/pyproject_fmt-2.27.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b44079ecf57addebfb06c8cbba5961d69eaea8e5dc5a21b781804c0c49a48b7", size = 5546145, upload-time = "2026-08-03T22:54:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/79/4d3fdfbdf4afe879dab75555af1387fca77f0f3397c36d79f9e3092d09d2/pyproject_fmt-2.27.0-cp310-abi3-win_arm64.whl", hash = "sha256:b226c744ab4d1a6918ecbccbcc2d73d0e7a3527d73c44dc8c5a3775c56dc0dd0", size = 5068780, upload-time = "2026-08-03T22:54:22.377Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/b621c3cbc19eb81d2473aab02c09bd2418929f3d61d9f3c92f127f285ba4/pyproject_fmt-2.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ce05601d0d266587a8cd08af6e1cc19935e1dc3be7d6f83fa20537d6a242512d", size = 5344535, upload-time = "2026-08-03T22:54:24.127Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/f7e51e7f59d586e0797de2f533029c71cd888025b33d51dbb3967ffd2f10/pyproject_fmt-2.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:97901311988fd3601df115af63cd285317ad60b4db79c4598efcbe9f8a7816c2", size = 5097006, upload-time = "2026-08-03T22:54:25.99Z" }, + { url = "https://files.pythonhosted.org/packages/e1/13/cd92d6bab4f1075425982e3e6c85bc300e320c9e8e53e96028386192ef73/pyproject_fmt-2.27.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:624a4aa2f4553697dd078f1c00edc8659a5e5ccfd07325611358332f0a5dcf13", size = 5267186, upload-time = "2026-08-03T22:54:27.869Z" }, + { url = "https://files.pythonhosted.org/packages/76/99/948a3f766935723b17143ee23bdf15b7b35bc9248fb14cbd019ece93ce2a/pyproject_fmt-2.27.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cdec6b19d887bb27df0a06448e9d0a4c3366c4a0e5b6ad308262d379a904d337", size = 5662485, upload-time = "2026-08-03T22:54:29.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/7700d5c1a123ed76289b673f68f2f558faddeea4f60b2d58634acd91497c/pyproject_fmt-2.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00833606878a5cf8e0d980e9175788a5b6d44a0d68d88d5723ec2befafddda06", size = 5266508, upload-time = "2026-08-03T22:54:32.135Z" }, + { url = "https://files.pythonhosted.org/packages/31/46/4b177d5606d3632a8690bc8489ae34984e712e58aa52496c811f1706d671/pyproject_fmt-2.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48d5f4da35d77905bf2234f12b787fecb2247d0573793f31c075a1469333bf39", size = 5837669, upload-time = "2026-08-03T22:54:34.081Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9d/c13f03014b2538a515caa83b6a41ef08b29f6ee6af7ac3cd75f0732384a6/pyproject_fmt-2.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cc014e22b1e10d5f0d7b51faa281d310f04a646607ed4e620cf031bd5d7f57e8", size = 5543009, upload-time = "2026-08-03T22:54:36.049Z" }, + { url = "https://files.pythonhosted.org/packages/0b/21/341bc93ce40488a0e49bfc45e3328e11d4011ce655f2f45bd1f91f989199/pyproject_fmt-2.27.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3e104c0a28212af3e2b4228b04c83220c762ce4f0bd0ec7bb2f2230361a7f154", size = 5064853, upload-time = "2026-08-03T22:54:38.72Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/34cef5210591b288e0cc44138ecd4549ca93e933e11c26d4449129d34ae0/pyproject_fmt-2.27.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:f84843bfc074b8defa4376113a9d17b9d5f7ec10c5c5185662427409b4b9fea9", size = 5344052, upload-time = "2026-08-03T22:54:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/67/5a/2ec9937459827018bb6983e29a5c6f62d519ae3019b359807c068cd2ca46/pyproject_fmt-2.27.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3291f2526bb0071801dd1734ba10062f7363a82123a61810aebf2d823a6bc52b", size = 5096312, upload-time = "2026-08-03T22:54:42.57Z" }, + { url = "https://files.pythonhosted.org/packages/45/14/fbaa36049d3aaec9f82b83d699cdb9fc2a46dad2ca740168111c312b4cb2/pyproject_fmt-2.27.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:0d7f55de0460109fb892323384ce1bb6cde5b3756a7c62b1465884b42ac648ed", size = 5268557, upload-time = "2026-08-03T22:54:44.37Z" }, + { url = "https://files.pythonhosted.org/packages/95/c9/dbffa555dd0686c1309e72274d1fb5ad2feaed40469caeec62f5dc32717a/pyproject_fmt-2.27.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:9960ec75a2e1275c7519309838e196cbd2863db918ac8d6cf2edb89d715dbfc3", size = 5662628, upload-time = "2026-08-03T22:54:46.514Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/a41d21f984207e719cf338165440758b561ecd6875c5f682cfe578b9424b/pyproject_fmt-2.27.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:880f3002f702e8c888fd4b129ca699d318ebc3266ed12cc7a2f7b411e56d641f", size = 5267349, upload-time = "2026-08-03T22:54:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/76/08/a51aa810d4a5518bfd8cb5bf49f55864a7cf88f5aa190fcf8627c6364c80/pyproject_fmt-2.27.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8a6e68a0780828853850c0e284fd67e75c14e7e40d681894f40bca5d17b0d93b", size = 5837708, upload-time = "2026-08-03T22:54:50.846Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyrefly" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" }, + { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" }, + { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" }, + { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" }, + { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pyroma" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "build" }, + { name = "docutils" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/68/a91ab78e5d7ff88eaaa10cefc948a07723d263e9a443e3650b32fbf0ac01/pyroma-5.0.1.tar.gz", hash = "sha256:703ae972e53e16be836966d03cd387906ecf32d64992345a61f2ed15805aee56", size = 67392, upload-time = "2025-12-09T10:10:22.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/cd/300d42aa7675d2ce66fea380177c19ad9f8ffad01295160a06e257360d73/pyroma-5.0.1-py3-none-any.whl", hash = "sha256:e71fd3e0f213b36870a607eccf491241dbadf5462ec1cdda94d08bfa1c26951e", size = 23012, upload-time = "2025-12-09T10:10:20.578Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pyteenybrisque" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/43/294f291398c93ed695ef208333857394763e50581da3fcef4a3aded3228f/pyteenybrisque-0.1.1.tar.gz", hash = "sha256:eb804b121146056ec6b6d08581f6f19983b565df11e746af0ebe802575c5e56e", size = 182546, upload-time = "2026-05-03T19:28:40.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/e6/40048a4eb960ee9fc48f7cade4d98274dac591ecc733beacfb3babacf703/pyteenybrisque-0.1.1-py3-none-any.whl", hash = "sha256:d3437290463c62c8479300fad0ab579e1357ce5bfc967e299a4916e24877a426", size = 182791, upload-time = "2026-05-03T19:28:39.587Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-beartype-tests" +version = "2026.4.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/4f/14167cd06fa425dc70eb486942e5ef402c738984726703584bba55353809/pytest_beartype_tests-2026.4.26.tar.gz", hash = "sha256:a986f59466243b616606279b3c6c00e7e171ee0b5aad4702922ea20fd1a1b52c", size = 88278, upload-time = "2026-04-26T17:12:24.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/2b/b9c1799582a00a9746f875deecd4449d0dc30254600c77231837bb67c6ec/pytest_beartype_tests-2026.4.26-py3-none-any.whl", hash = "sha256:e0c19a6708a14e4f97acb18ec94151550d70f736733b36d6cd621a6481cea99d", size = 5718, upload-time = "2026-04-26T17:12:22.889Z" }, +] + +[[package]] +name = "pytest-partition-check" +version = "2026.8.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/24/abc56101c2c1aa27df3292489131e1c236d656e7916194d568a49c01d27b/pytest_partition_check-2026.8.10.1.tar.gz", hash = "sha256:aff56486057490b5596805ebdf780473003c304e4aa046a66862e20b3235894f", size = 20383, upload-time = "2026-08-10T16:17:15.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/90/eb3568a318b7cef2e1dcfe04f00c4a871e00ff850e692d402879ef7be4e7/pytest_partition_check-2026.8.10.1-py3-none-any.whl", hash = "sha256:d73ea48f1a3a739410976731ce7829ce96de842b64f1f81ea165c360ab4aab38", size = 9897, upload-time = "2026-08-10T16:17:13.754Z" }, +] + +[[package]] +name = "pytest-retry" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/5b/607b017994cca28de3a1ad22a3eee8418e5d428dcd8ec25b26b18e995a73/pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f", size = 19977, upload-time = "2025-01-19T01:56:13.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/ff/3266c8a73b9b93c4b14160a7e2b31d1e1088e28ed29f4c2d93ae34093bfd/pytest_retry-1.7.0-py3-none-any.whl", hash = "sha256:a2dac85b79a4e2375943f1429479c65beb6c69553e7dae6b8332be47a60954f4", size = 13775, upload-time = "2025-01-19T01:56:11.199Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-mock" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901, upload-time = "2024-03-29T03:54:29.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, +] + +[[package]] +name = "requests-mock-flask" +version = "2026.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpretty" }, + { name = "httpx" }, + { name = "requests-mock" }, + { name = "responses" }, + { name = "respx" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/36/dab370235de8fe5404d79eab4f53e724b1e653568e4a77afb75f14c807d1/requests_mock_flask-2026.4.2.tar.gz", hash = "sha256:0fada5104d187cc5ebb22c27dec367cc2174114c2152c8c57c4e0f96fd9dcb49", size = 26481, upload-time = "2026-04-02T03:43:36.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/2955c6b40be56786a788029f354e5d7e4c5f56b75cdd62b6a29cc4cdb5bd/requests_mock_flask-2026.4.2-py2.py3-none-any.whl", hash = "sha256:ea88de696f4c33ef77f544fd5a05d3ac123b066e5d4fa83468293e174caefc56", size = 6734, upload-time = "2026-04-02T03:43:34.359Z" }, +] + +[[package]] +name = "requirements-parser" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/1a/5f3c22d38bf1d87d1f4a961489d9eba35c4370a21395562d94410cdd0e73/requirements_parser-0.13.1.tar.gz", hash = "sha256:78811383b2089b6c5197a1431bc2c12ff950245edca39a23eea3460782038dd3", size = 22783, upload-time = "2026-06-18T07:52:25.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f9/15b44d5e4401b0013bbcefe3c09d7bfddcce28cc3d41b1d3077bcedf5b1f/requirements_parser-0.13.1-py3-none-any.whl", hash = "sha256:6e385663eb32589d16e5b22bb6e5251a57908e73803ffff438b53cd6ea2056e0", size = 14926, upload-time = "2026-06-18T07:52:24.171Z" }, +] + +[[package]] +name = "responses" +version = "0.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/1a/4af3e6d659394b809838490b144e4ab8d7ed3b9fecc7ca78f5d2f79b1a3d/responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8", size = 84030, upload-time = "2026-07-03T16:44:50.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/28/693e1d9ebf72baa062ded80d837a035b86ce75eda5a269379e9e2b1008a8/responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364", size = 35609, upload-time = "2026-07-03T16:44:49.1Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "restructuredtext-lint" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/e6/eefcad2228f4124f17e01064428fbcd0ade06a274f3063ce3a126a569d6b/restructuredtext_lint-2.0.2.tar.gz", hash = "sha256:dd25209b9e0b726929d8306339faf723734a3137db382bcf27294fa18a6bc52b", size = 17494, upload-time = "2025-11-23T08:05:18.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/63/ac52b32b33ae62f2076ed5c4f6b00e065e3ccbb2063e9a2e813b2bfc95bf/restructuredtext_lint-2.0.2-py3-none-any.whl", hash = "sha256:374c0d3e7e0867b2335146a145343ac619400623716b211b9a010c94426bbed7", size = 14198, upload-time = "2025-11-23T08:05:23.267Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "roman" +version = "5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/7c/3901b35ed856329bf98e84da8e5e0b4d899ea0027eee222f1be42a24ff3f/roman-5.2.tar.gz", hash = "sha256:275fe9f46290f7d0ffaea1c33251b92b8e463ace23660508ceef522e7587cb6f", size = 8185, upload-time = "2025-11-11T08:03:57.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/14/ea3cdd7276fcd731a9003fe4abeb6b395a38110ddff6a6a509f4ee00f741/roman-5.2-py3-none-any.whl", hash = "sha256:89d3b47400388806d06ff77ea77c79ab080bc127820dea6bf34e1f1c1b8e676e", size = 6041, upload-time = "2025-11-11T08:03:56.051Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "ruyaml" +version = "0.91.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distro" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/75/abbc7eab08bad7f47887a0555d3ac9e3947f89d2416678c08e025e449fdc/ruyaml-0.91.0.tar.gz", hash = "sha256:6ce9de9f4d082d696d3bde264664d1bcdca8f5a9dff9d1a1f1a127969ab871ab", size = 239075, upload-time = "2021-12-07T16:19:58.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/9a/16ca152a04b231c179c626de40af1d5d0bc2bc57bc875c397706016ddb2b/ruyaml-0.91.0-py3-none-any.whl", hash = "sha256:50e0ee3389c77ad340e209472e0effd41ae0275246df00cdad0a067532171755", size = 108906, upload-time = "2021-12-07T16:19:56.798Z" }, +] + +[[package]] +name = "selenium" +version = "4.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a2/213190a606bc036b4db1b8129f399964988872a555b50dfbfddf612d333c/selenium-4.47.0.tar.gz", hash = "sha256:4f6667c23080646e045fb91d2039687e88f549d667961f6ce85832b17384b68e", size = 1014095, upload-time = "2026-08-10T17:54:11.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/0b/652575986d2ed03d29103d8574580a03aefe50d243d225b441e2375bd0f6/selenium-4.47.0-py3-none-any.whl", hash = "sha256:2eac6b8e7c017f57ecc40820383da8881a6fd7a90ea555c1b0af322f2344b347", size = 9511195, upload-time = "2026-08-10T17:54:09.369Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "shellcheck-py" +version = "0.11.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/455b097417b3df3d330eff029c72c32f08b25739e3010acb30ad06d268ef/shellcheck_py-0.11.0.1.tar.gz", hash = "sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc", size = 3139, upload-time = "2025-08-09T17:53:42.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/27/d75b03e5458cefdb6d3b674566cd20476c3e4d3fe6cc9d68b7e3b854b296/shellcheck_py-0.11.0.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8", size = 6774472, upload-time = "2025-08-09T17:53:34.573Z" }, + { url = "https://files.pythonhosted.org/packages/61/ac/2a84c37171c0cf5a10ea4b0a27d43eb0a1d29bd98b49c2c5ffe17ad24bbe/shellcheck_py-0.11.0.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0", size = 11381835, upload-time = "2025-08-09T17:53:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/96/55/250e0e3367613a5c22bd82e33b16b889287d81ab0f7dda67e6514a4cccf4/shellcheck_py-0.11.0.1-py2.py3-none-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05", size = 3800600, upload-time = "2025-08-09T17:53:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/15/5b/bb14c0a7474463b1aa3c09e866cb172dffc66ed2993b7ea8f1db581e86ee/shellcheck_py-0.11.0.1-py2.py3-none-win_amd64.whl", hash = "sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759", size = 8027541, upload-time = "2025-08-09T17:53:40.889Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "shfmt-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/d5/c2ad5c6593a34da7344cf39bde65763e8cda752589074ba1619e55b317ad/shfmt_py-4.0.0.tar.gz", hash = "sha256:1e5fdacf40aabaa77a97639d52a6220df0893b46658d82b7f136f4e66e2b2fb0", size = 11947, upload-time = "2026-05-13T09:25:50.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/1d/8f72824e2a0e06dc0bc2687baacaba0573be7d2e93c01d1e895fddd8c13e/shfmt_py-4.0.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:75a4919a03fb3bcff9795e3cc7b971e37e74905654d2f11605001cab42e5f92f", size = 1343695, upload-time = "2026-05-13T09:25:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9564a2c2a76fbec94db1b3a3c37a9a1d00e7eafca2cdd2e0d19082618d7e/shfmt_py-4.0.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb3d236163ff39c7790953e069938caf247e7646399f7a059f00f65d4e6916d6", size = 1237947, upload-time = "2026-05-13T09:25:44.767Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a9/6fce944efa530db941edd11388d70dc7384aaf12169a3b0847b6a6c987b0/shfmt_py-4.0.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:4701336c3cb5f3959a5e85481b14f02054ea094b3c666f3d04649bbe10de3c25", size = 1218771, upload-time = "2026-05-13T09:25:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/64/43/e3965a25bb39555f2791c6860214f62b6f976f9ac7e9786073364bcdd9a6/shfmt_py-4.0.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:e57877abe0177a9da7bbb5390fe7e96aa19b00958189a025634039aef8834d44", size = 1350939, upload-time = "2026-05-13T09:25:47.584Z" }, + { url = "https://files.pythonhosted.org/packages/95/20/db2430d9262d2cffadcad2b330441e13031f1ab849ec069659edb7f23257/shfmt_py-4.0.0-py2.py3-none-win_amd64.whl", hash = "sha256:bd4f3d36264d4ba8b014ff73e5e702aaa2345845c021f563480128de3705135b", size = 1427721, upload-time = "2026-05-13T09:25:48.865Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4f/4fd5583678bb7dc8afa69e9b309e6a99ee8d79ad3a4728f4e52fd7cb37c7/sphinx_autodoc_typehints-3.5.2.tar.gz", hash = "sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb", size = 37839, upload-time = "2025-10-16T00:50:15.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f2/9657c98a66973b7c35bfd48ba65d1922860de9598fbb535cd96e3f58a908/sphinx_autodoc_typehints-3.5.2-py3-none-any.whl", hash = "sha256:0accd043619f53c86705958e323b419e41667917045ac9215d7be1b493648d8c", size = 21184, upload-time = "2025-10-16T00:50:13.973Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-jinja2-compat" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "standard-imghdr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/43313781f29e8c6c46fec907430310172d6f207e95e4fbea9289990fbbfe/sphinx_jinja2_compat-0.4.1.tar.gz", hash = "sha256:0188f0802d42c3da72997533b55a00815659a78d3f81d4b4747b1fb15a5728e6", size = 5222, upload-time = "2025-08-06T20:06:25.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/c8/4fd58c1000d7f8f5572c507f4550d2e2d9741e500c68eb2e3da17cbe5a85/sphinx_jinja2_compat-0.4.1-py3-none-any.whl", hash = "sha256:64ca0d46f0d8029fbe69ea612793a55e6ef0113e1bba4a85d402158c09f17a14", size = 8123, upload-time = "2025-08-06T20:06:24.947Z" }, +] + +[[package]] +name = "sphinx-lint" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polib" }, + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/19/9258497fee6e2a0bdb93e8ecea6ef6864afb5d83e996a1606a853f96c658/sphinx_lint-1.0.2.tar.gz", hash = "sha256:4e7fc12f44f750b0006eaad237d7db9b1d8aba92adda9c838af891654b371d35", size = 36870, upload-time = "2025-11-19T08:28:12.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/62/f29a2988ff706ac01d3c63d0b4cc4ed62d2c83b447916e0317790ca156cf/sphinx_lint-1.0.2-py3-none-any.whl", hash = "sha256:edcd0fa4d916386c5a3ef7ef0f5136f0bb4a15feefc83c1068ba15bc16eec652", size = 20670, upload-time = "2025-11-19T08:28:10.656Z" }, +] + +[[package]] +name = "sphinx-paramlinks" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/21/62d3a58ff7bd02bbb9245a63d1f0d2e0455522a11a78951d16088569fca8/sphinx-paramlinks-0.6.0.tar.gz", hash = "sha256:746a0816860aa3fff5d8d746efcbec4deead421f152687411db1d613d29f915e", size = 12363, upload-time = "2023-08-11T16:09:28.604Z" } + +[[package]] +name = "sphinx-prompt" +version = "1.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "docutils" }, + { name = "idna" }, + { name = "jinja2" }, + { name = "pygments" }, + { name = "requests" }, + { name = "sphinx" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/a3/91293c0e0f0b76d0697ba7a41541929ca3f5457671d008bd84a9bde17e21/sphinx_prompt-1.10.2.tar.gz", hash = "sha256:47b592ba75caebd044b0eddf7a5a1b6e0aef6df587b034377cd101a999b686ba", size = 5566, upload-time = "2025-11-28T09:23:18.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/f4/44ce4d0179fb4e9cfe181a8aa281bba23e40158a609fb3680774529acaaa/sphinx_prompt-1.10.2-py3-none-any.whl", hash = "sha256:6594337962c4b1498602e6984634bed4a0dc7955852e3cfc255eb0af766ed859", size = 7474, upload-time = "2025-11-28T09:23:17.154Z" }, +] + +[[package]] +name = "sphinx-pyproject" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dom-toml" }, + { name = "domdf-python-tools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/97/aa8cec3da3e78f2c396b63332e2fe92fe43f7ff2ad19b3998735f28b0a7f/sphinx_pyproject-0.3.0.tar.gz", hash = "sha256:efc4ee9d96f579c4e4ed1ac273868c64565e88c8e37fe6ec2dc59fbcd57684ab", size = 7695, upload-time = "2023-08-18T21:43:45.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/d5/89cb47c6399fd57ca451af15361499813c5d53e588cb6e00d89411ce724f/sphinx_pyproject-0.3.0-py3-none-any.whl", hash = "sha256:3aca968919f5ecd390f96874c3f64a43c9c7fcfdc2fd4191a781ad9228501b52", size = 23076, upload-time = "2023-08-18T21:43:43.808Z" }, +] + +[[package]] +name = "sphinx-substitution-extensions" +version = "2026.8.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "docutils" }, + { name = "myst-parser" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/39/d59f3efeea7bf81116f2cc832b6f7441b2cc1529082559de7bc0abd6a7ee/sphinx_substitution_extensions-2026.8.13.tar.gz", hash = "sha256:69c4f4aba98cf0546fe68e1676c9984bf164bf5ea78cddf899c748547dba412d", size = 44305, upload-time = "2026-08-13T09:09:04.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/df/ccecfbfef61f007f9831d47f00f7c3a63e3b2f50bc65fc68fc1522a3ace7/sphinx_substitution_extensions-2026.8.13-py3-none-any.whl", hash = "sha256:31963b1364aea56661b4085d32555983ac44f5ac263f77a310bc34b9d00a9de6", size = 12078, upload-time = "2026-08-13T09:09:03.536Z" }, +] + +[[package]] +name = "sphinx-tabs" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/30/ca5b0de830f369968d8e3483dd45a8908fd10169c05cd9837f0bd075982e/sphinx_tabs-3.5.0.tar.gz", hash = "sha256:91dba1187e4c35fd37380a56ac228bbd54c6c649b2351829f3bf033718277537", size = 17006, upload-time = "2026-03-03T23:00:30.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/45/6adc5efeb19fd5fed4027e520b5c668ce58236a2b271ade5533c4c116276/sphinx_tabs-3.5.0-py3-none-any.whl", hash = "sha256:154be49de4d5c8249ea08c5d9bf88ca8f9c31e00a178305a93cbc33e000339e5", size = 9871, upload-time = "2026-03-03T23:00:28.89Z" }, +] + +[[package]] +name = "sphinx-toolbox" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apeye" }, + { name = "autodocsumm" }, + { name = "beautifulsoup4" }, + { name = "cachecontrol", extra = ["filecache"] }, + { name = "dict2css" }, + { name = "docutils" }, + { name = "domdf-python-tools" }, + { name = "filelock" }, + { name = "html5lib" }, + { name = "roman" }, + { name = "ruamel-yaml" }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-jinja2-compat" }, + { name = "sphinx-prompt" }, + { name = "sphinx-tabs" }, + { name = "tabulate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/89/7a309544590129c8c68b301d08d7a0660e7b07866c949e447eb2e10d4efa/sphinx_toolbox-4.3.0.tar.gz", hash = "sha256:07ec26176744ee3abe3c1eb4407419e81468f4536f332dcafc4e3240b0d6fae2", size = 117606, upload-time = "2026-07-28T09:33:40.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/17/9f38bb5811f010f9ccfe3f683e7a32bdf72e520c890490952504e86b7a2b/sphinx_toolbox-4.3.0-py3-none-any.whl", hash = "sha256:edab650523d61d410f13e3f288a8067227bb6682701cf79896f08325a375e1e7", size = 198659, upload-time = "2026-07-28T09:33:38.715Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-httpdomain" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/a2/b9b96904f691a0b4ccb8277a72b4ec351590286b44a433d0cefe78703c2b/sphinxcontrib_httpdomain-2.0.0.tar.gz", hash = "sha256:9e4e8733bf41ee4d9d5f9eb4dbf3cc2c22a665221ba42c5c3ae181b98af8855d", size = 17155, upload-time = "2026-02-04T21:23:56.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/48/9524b2a8cd11a3802a266aa4631ae7842cd764cf9bf3701bbde547b040b5/sphinxcontrib_httpdomain-2.0.0-py3-none-any.whl", hash = "sha256:e968775c9994f8139cb6ff91e1f6a8557396a2cc08073997eed10d9b39f96df3", size = 26137, upload-time = "2026-02-04T21:23:55.444Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sphinxcontrib-spelling" +version = "8.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyenchant" }, + { name = "requests" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/cd/fa8039cedce6295644ff5f03367742d21e7922da426e8c666b7f5a213682/sphinxcontrib_spelling-8.0.2.tar.gz", hash = "sha256:afbc7b8e93721ab88f12bdd39d848b92017b3763b9ed6226b4b0e54b06664fea", size = 30955, upload-time = "2025-11-28T15:31:50.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c5/bcd32aa919c9e1652cca5bed6478202656a320c407793b547f8c16c179e3/sphinxcontrib_spelling-8.0.2-py3-none-any.whl", hash = "sha256:db8b3b2945683d49e87a8a5133d2b8ed4206cb593038b986ca8686a485f9980d", size = 14587, upload-time = "2025-11-28T15:31:48.957Z" }, +] + +[[package]] +name = "sphinxcontrib-towncrier" +version = "0.5.0a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, + { name = "towncrier" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/fe/72ed57093e28af10595c50839b183c5fdf0952482e9ef0ca6eb90eb85c5d/sphinxcontrib_towncrier-0.5.0a0.tar.gz", hash = "sha256:294e69df6e275e7a86df7ea6a927cc7c28c2c370a884cd5c45de6ec989858f27", size = 62453, upload-time = "2025-02-28T01:59:16.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/5c/f7e39f243636a5e1894f2f5a72579977bf3968922afdb75175ee45062066/sphinxcontrib_towncrier-0.5.0a0-py3-none-any.whl", hash = "sha256:11d130c3ad5e4649821d543c4ea7ab64bbe78df4d859ef94f4298e7845dc0f59", size = 12609, upload-time = "2025-02-28T01:59:15.178Z" }, +] + +[[package]] +name = "standard-imghdr" +version = "3.10.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/d2/2eb5521072c9598886035c65c023f39f7384bcb73eed70794f469e34efac/standard_imghdr-3.10.14.tar.gz", hash = "sha256:2598fe2e7c540dbda34b233295e10957ab8dc8ac6f3bd9eaa8d38be167232e52", size = 5474, upload-time = "2024-04-21T18:55:10.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/d0/9852f70eb01f814843530c053542b72d30e9fbf74da7abb0107e71938389/standard_imghdr-3.10.14-py3-none-any.whl", hash = "sha256:cdf6883163349624dee9a81d2853a20260337c4cd41c04e99c082e01833a08e2", size = 5598, upload-time = "2024-04-21T18:54:48.587Z" }, +] + +[[package]] +name = "stevedore" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/dd/04d56c2a5232358df41f3d0f0e31833d378b6c8ed7803a6b1b7867b0eba6/stevedore-5.9.0.tar.gz", hash = "sha256:abbd0af7a38a8bbb1d6adea2e35b17609cf004eaac323e88a8d8963640dd2b3c", size = 514850, upload-time = "2026-07-02T11:38:08.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8d/008761f6e1000600e5303db30d05724bdcf3d2d186cbb59fac79b52e39ed/stevedore-5.9.0-py3-none-any.whl", hash = "sha256:e520945d4c257700eddc1eb1d79df04b2ea578eef185e0e3fa5b442fc848d3f7", size = 54463, upload-time = "2026-07-02T11:38:07.43Z" }, +] + +[[package]] +name = "strict-kwargs" +version = "2026.7.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ty" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/ea/5be0b5ba196632ae08842b7e7a45c2baa4ad14be3aa0c410e9ffede761d2/strict_kwargs-2026.7.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94a8679e751918df94239ae4513ce99d89653cbdf55eb7d9181037ded0a79562", size = 2897038, upload-time = "2026-07-24T09:33:29.353Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/5e23899b7ff873217aead4d0f6a89fa50b1fd7c0cac04c93d713ae9ba4a8/strict_kwargs-2026.7.24-py3-none-manylinux_2_39_x86_64.whl", hash = "sha256:b788a347f66f5ee1ebc424632a2bce223ed0162da97a12327af75e0438df2836", size = 3084814, upload-time = "2026-07-24T09:33:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/43/ad/ef1e18303b9684e81dc9ffdb4ec3e8bb5065d5b8ec1b1da6cae0451341cc/strict_kwargs-2026.7.24-py3-none-win_amd64.whl", hash = "sha256:b727e83eff48ef0c6e14618f1ce04e70e4b9b8c7f0ee5f2a546bf8d30e77c729", size = 2799438, upload-time = "2026-07-24T09:33:32.471Z" }, +] + +[[package]] +name = "sybil" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/50135b55ba14509654b2f624eaf7e318d654182ebe9f712fb74e98e418d0/sybil-10.1.0.tar.gz", hash = "sha256:062249c8886a0ab19e45d1c3afd5631ec806e7a95cf5153c96560f5e47756cbd", size = 82376, upload-time = "2026-06-13T09:40:44.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/fe/4094754188b52f8333ba24b377192918936597769a574a9dd8446f69e1f1/sybil-10.1.0-py3-none-any.whl", hash = "sha256:b3015f7e0ca3fe197ae67117c440710b62ea500078d4414ebd0ab804d12c9897", size = 40930, upload-time = "2026-06-13T09:40:43.139Z" }, +] + +[[package]] +name = "sybil-extras" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "markdown-it-py" }, + { name = "myst-parser" }, + { name = "sybil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/3c/27b1055b5afb809f055c68ba5858a4a498757f54f90827e16918ce53f39d/sybil_extras-2026.7.19.tar.gz", hash = "sha256:4ee756a2da38287a957bde7edbf295951cdd65407ca4344a1726dfff3c1d64ba", size = 118325, upload-time = "2026-07-19T13:47:44.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/1d/d09751fe1bc2d9029c5123ce4c5fb11ead2d09c8f227a8f4658ebe3e7afa/sybil_extras-2026.7.19-py3-none-any.whl", hash = "sha256:794cb004f1f8d2b7e32b7ca1af455972d382053c1c0d03ac2da8c4089616c9fb", size = 89540, upload-time = "2026-07-19T13:47:43.107Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] + +[[package]] +name = "towncrier" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, +] + +[[package]] +name = "trio" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/dc/a2d25ed73ad49cfd79bf18d262577c3731c98e382284e28d522f49a0df35/trio-0.34.0.tar.gz", hash = "sha256:63b9485408bdfdde544fced107045a8c0086cdc4bd0ef2f797b9e0dd111b964b", size = 607457, upload-time = "2026-08-11T00:33:42.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/1f/555f1364bed52a92a864181962b77f1b15adadeacf23b86105324363e461/trio-0.34.0-py3-none-any.whl", hash = "sha256:6c7c9f49917694dcdcd5f67abd168df5599eca480d61f29854d17a61a75c2f05", size = 511840, upload-time = "2026-08-11T00:33:40.552Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2026.6.1.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" }, +] + +[[package]] +name = "ty" +version = "0.0.70" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ed/38a8ab52f1d7c3ed701442a31b23ba774cbc5d6909f2c00da9e1f3c590f9/ty-0.0.70.tar.gz", hash = "sha256:a01bebc128b4081c16002965d906fccb21323d69bb709b9108c1f2406bcffced", size = 6601156, upload-time = "2026-08-10T23:20:26.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/c5/ddd6cc5657fd3da85591b264f4dabb1ce5e5b535e73fd5174991c294978b/ty-0.0.70-py3-none-linux_armv6l.whl", hash = "sha256:4fb2d2e55e2160c07152361be2e1a26fdd4f6261055731317d5972daf1749935", size = 12537215, upload-time = "2026-08-10T23:19:43.512Z" }, + { url = "https://files.pythonhosted.org/packages/8b/8a/acc9b34331cde81e0d63cda92d4db9e4042def5b1efa7d47431ff1d30d17/ty-0.0.70-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9b04d4c21cb029501c05598ca21e9c0829c0b2e35a7ceba1e5b78014c1e8104d", size = 12149070, upload-time = "2026-08-10T23:19:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cd/51ac2708f4d077058a0bfeefa40d630883c5ec7a82fd2c15c86519140235/ty-0.0.70-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c605ebf2643f5e64ec4bcb269640a1aa85966d29fa888fb746039c28570369b2", size = 11625729, upload-time = "2026-08-10T23:19:48.535Z" }, + { url = "https://files.pythonhosted.org/packages/b7/99/afc4fe7e630100dc782ff0cdc8c59c01acfb05299551ff0ef49c93814320/ty-0.0.70-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c38ea76e12909c29e18fcd295ff223b63f3701c1d1c34db2cdc9736340b9de2", size = 12204810, upload-time = "2026-08-10T23:19:51.845Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/8cabc3c8ad4c3a02e585ecff12a71ff8e5881f8a00ee71745fd765201da5/ty-0.0.70-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1ee6bf4edaabf7bd0307f0c0f9ddc2204df0390820fe89f6a9de65f07722aba", size = 12308114, upload-time = "2026-08-10T23:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/44/7e/bb4e552ecd68bb490bae9368ef323e3d0c79ef85a811857139a2d0f59ddb/ty-0.0.70-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5eef3e11d7d6b800ef66da0cce8ea24f8be804d1ebf359683426cfe848729bf8", size = 13072240, upload-time = "2026-08-10T23:19:56.872Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c4/1861e1d554e5b6e0d11b3a9f36d40c372253f44a3c4e56ad4bf840f2801e/ty-0.0.70-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95e4ae7c2599197c9d89652e49ca344ab224f7d12376e6ad8beb0587e8ff83f", size = 13497678, upload-time = "2026-08-10T23:19:59.191Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/3b22442133e8f641485e59e463a070a31184db0d6456851f871db8f59763/ty-0.0.70-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06aca758d1e0016c0a1f57fe9d8de7a21ff83f692306f747a0d97f32de24e27f", size = 13232510, upload-time = "2026-08-10T23:20:01.456Z" }, + { url = "https://files.pythonhosted.org/packages/84/b8/911f1e6885b5485b6e1d29aaab19ce5e6deeb3e931b6ad82296da4f22051/ty-0.0.70-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d81825524f1b57ecbcb5fce7d61fb159cb4837a6167a4569309c9fa7fc15a77d", size = 12817128, upload-time = "2026-08-10T23:20:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a6/9affc3ca11c32d75b348a66144ab83335fa7fa7100b1581356725e15a668/ty-0.0.70-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3287dfb09f7320ef28f114f5f9aae5f697b7b1a6ee37fb2a4a3be94481c1b4f8", size = 13089208, upload-time = "2026-08-10T23:20:06.574Z" }, + { url = "https://files.pythonhosted.org/packages/f0/cb/4776108ea08ec4013b71375b101be9c8a632967cd10f24abbdd4664d66f4/ty-0.0.70-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9fb1877f6401cdaac4db46c5bf762f327482ba5f891420ea462ebd15d0de4185", size = 12148890, upload-time = "2026-08-10T23:20:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/814e71f698d9231ef6412bd2c1499d81c6f86f66c2764e952c991e2d6da5/ty-0.0.70-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e78f4997dbb0db2d2270210eb3694466206e5b3a11985e24148770e702158a25", size = 12327084, upload-time = "2026-08-10T23:20:11.023Z" }, + { url = "https://files.pythonhosted.org/packages/3b/69/9f34bba534c5d1ace391d300ead4f1971f4348c5459e66dc466cfd60661c/ty-0.0.70-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cf758d3b2dad910c9b1d22d2d62fea894b5bc9acd3c00365e8a73ace6090a452", size = 12604372, upload-time = "2026-08-10T23:20:13.366Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ff/abb34674517b29a8e2489a39c4e01930fcf6aab993a7d6b33aa97f119117/ty-0.0.70-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0d338761617279a4fb6a83e7fad7e21126394637b8c45e13e196b2bb675f39b2", size = 12917800, upload-time = "2026-08-10T23:20:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1a/f8289572f4fad5cb16c883b94638548166bc78cf3eb569f0cd9199eb10a0/ty-0.0.70-py3-none-win32.whl", hash = "sha256:a45642cf09dde91f0a3ce9b9e6fffda9779ff69d73f7347c18acf4fb45007c07", size = 11921482, upload-time = "2026-08-10T23:20:18.244Z" }, + { url = "https://files.pythonhosted.org/packages/17/ae/8739d7618b4670c3ee4f52d641d53be87928bd121d8bda9c0e3450500b75/ty-0.0.70-py3-none-win_amd64.whl", hash = "sha256:33e7941a926cf39b82553911a59a6ed68ec98c3d3d5a415df633f4d1cd051e6c", size = 12986994, upload-time = "2026-08-10T23:20:20.53Z" }, + { url = "https://files.pythonhosted.org/packages/f1/44/2bc3301ba4356ad8866daac9f8cfb3687953e52d076ea204f113b9304c42/ty-0.0.70-py3-none-win_arm64.whl", hash = "sha256:0d380f735d52b1d4b773193f8f5c58c065725eab1ebb0008d38a7b830de14f47", size = 12301082, upload-time = "2026-08-10T23:20:23.816Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, +] + +[[package]] +name = "types-docker" +version = "7.2.0.20260811" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/e9/3adcba90f6ff01b7d1a0ebcedcf41178f61b22f36b2ec188d94ad85550d2/types_docker-7.2.0.20260811.tar.gz", hash = "sha256:d5f709c602c1b7a8fb0aa3c7acaf6f963c147da7bee9b2ef431b567e49ad2e45", size = 36802, upload-time = "2026-08-11T03:24:24.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/ac/7f374cd9c19388902e40649cbfc8cbdf497e42650c1a47c2a62996fe158e/types_docker-7.2.0.20260811-py3-none-any.whl", hash = "sha256:fa95839084e58d6ad2aeea9c702189c3b60b2a7895f5095cec93bd02a8900441", size = 51222, upload-time = "2026-08-11T03:24:23.169Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/bc/4eae18cd40c65798a16267572ba346c11f599d44b01603dbd843342042bc/typing_inspection-0.4.3.tar.gz", hash = "sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d", size = 76711, upload-time = "2026-08-10T09:39:18.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl", hash = "sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd", size = 14693, upload-time = "2026-08-10T09:39:16.693Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "vale" +version = "3.13.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/0d/6ebd7d020135888cc4d35290737871986ceabf176ba5e44827294429283a/vale-3.13.0.0.tar.gz", hash = "sha256:9c2482ecab515e58aa8d7e1a09f3d44ed674a07502786e22ff60c9d4fdc8493b", size = 5340, upload-time = "2025-10-28T13:20:41.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/05/92b9e4d3e3cb424d2a1aa6e070ee838f15f6739a90b06964156a24fe49ce/vale-3.13.0.0-py3-none-any.whl", hash = "sha256:b565197a5f6e430af7ccc59204e75c6067bbd091a0073d700efdd0fba21873ed", size = 5944, upload-time = "2025-10-28T13:20:40.35Z" }, +] + +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + +[[package]] +name = "vws-auth-tools" +version = "2024.7.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/17/421ff3a46cee7d952e3da3126160fb75ab7cb54e3fbbfa718a7ee9e120d4/vws_auth_tools-2024.7.12.tar.gz", hash = "sha256:e3949606f2366053ea97883992f8ecaf95030ea33f1b3cf769f99f9d43c0914b", size = 19097, upload-time = "2024-07-12T16:59:31.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/34/d6c791bffdc3cb2e920468d255d0fa23366cb8415c1ba3db26127cc1c789/vws_auth_tools-2024.7.12-py2.py3-none-any.whl", hash = "sha256:673bb0be98e2112a008f3146ab24a0276dc26de8c43cb40546d9a54821cb9e48", size = 5327, upload-time = "2024-07-12T16:59:30.287Z" }, +] + +[[package]] +name = "vws-python" +version = "2026.2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "httpx" }, + { name = "requests" }, + { name = "urllib3" }, + { name = "vws-auth-tools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/57/d5d9b68e421f77560e89fb5f8644aaaedd25cb7dbf290984e4ce59607236/vws_python-2026.2.25.1.tar.gz", hash = "sha256:7dec153b1dca2c483d9fdd3983497ea04821bea17f45e567c0da6a18657057ad", size = 50826, upload-time = "2026-02-25T08:55:14.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/9d/6df3d9f9329161cea0973d62d98dcb809d15323f1f9f35a2ad25db541d39/vws_python-2026.2.25.1-py2.py3-none-any.whl", hash = "sha256:f76b3cb0e72043b0d39b63ae5a1f7fc646423dfdb3c70fb0611f26f9d943eb54", size = 30469, upload-time = "2026-02-25T08:55:12.533Z" }, +] + +[[package]] +name = "vws-python-mock" +source = { editable = "." } +dependencies = [ + { name = "beartype" }, + { name = "flask" }, + { name = "httpx" }, + { name = "numpy" }, + { name = "opencv-contrib-python-headless" }, + { name = "pillow" }, + { name = "pydantic-settings" }, + { name = "pyteenybrisque" }, + { name = "requests" }, + { name = "responses" }, + { name = "respx" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "vws-auth-tools" }, + { name = "werkzeug" }, +] + +[package.optional-dependencies] +dev = [ + { name = "actionlint-py" }, + { name = "check-manifest" }, + { name = "check-wheel-contents" }, + { name = "coverage" }, + { name = "deptry" }, + { name = "dirty-equals" }, + { name = "doc8" }, + { name = "doccmd" }, + { name = "docker" }, + { name = "freezegun" }, + { name = "furo" }, + { name = "interrogate" }, + { name = "mypy", extra = ["faster-cache"] }, + { name = "mypy-strict-kwargs" }, + { name = "no-defaults" }, + { name = "prek" }, + { name = "pydocstringformatter" }, + { name = "pydocstyle" }, + { name = "pylint", extra = ["spelling"] }, + { name = "pylint-per-file-ignores" }, + { name = "pyproject-fmt" }, + { name = "pyrefly" }, + { name = "pyright" }, + { name = "pyroma" }, + { name = "pytest" }, + { name = "pytest-beartype-tests" }, + { name = "pytest-partition-check" }, + { name = "pytest-retry" }, + { name = "pytest-xdist" }, + { name = "pyyaml" }, + { name = "requests-mock-flask" }, + { name = "ruff" }, + { name = "shellcheck-py" }, + { name = "shfmt-py" }, + { name = "sphinx" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-lint" }, + { name = "sphinx-paramlinks" }, + { name = "sphinx-pyproject" }, + { name = "sphinx-substitution-extensions" }, + { name = "sphinx-toolbox" }, + { name = "sphinxcontrib-httpdomain" }, + { name = "sphinxcontrib-spelling" }, + { name = "sphinxcontrib-towncrier" }, + { name = "strict-kwargs" }, + { name = "sybil" }, + { name = "tenacity" }, + { name = "towncrier" }, + { name = "ty" }, + { name = "types-docker" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "urllib3" }, + { name = "vale" }, + { name = "vulture" }, + { name = "vws-python" }, + { name = "vws-test-fixtures" }, + { name = "vws-web-tools" }, + { name = "yamlfix" }, + { name = "zizmor" }, +] +release = [ + { name = "check-wheel-contents" }, + { name = "towncrier" }, +] + +[package.metadata] +requires-dist = [ + { name = "actionlint-py", marker = "extra == 'dev'", specifier = "==1.7.12.24" }, + { name = "beartype", specifier = ">=0.22.9" }, + { name = "check-manifest", marker = "extra == 'dev'", specifier = "==0.51" }, + { name = "check-wheel-contents", marker = "extra == 'dev'", specifier = "==0.6.3" }, + { name = "check-wheel-contents", marker = "extra == 'release'", specifier = "==0.6.3" }, + { name = "coverage", marker = "extra == 'dev'", specifier = "==7.15.4" }, + { name = "deptry", marker = "extra == 'dev'", specifier = "==0.25.1" }, + { name = "dirty-equals", marker = "extra == 'dev'", specifier = "==0.11" }, + { name = "doc8", marker = "extra == 'dev'", specifier = "==2.0.0" }, + { name = "doccmd", marker = "extra == 'dev'", specifier = "==2026.7.19" }, + { name = "docker", marker = "extra == 'dev'", specifier = "==7.2.0" }, + { name = "flask", specifier = ">=3.0.3" }, + { name = "freezegun", marker = "extra == 'dev'", specifier = "==1.5.5" }, + { name = "furo", marker = "extra == 'dev'", specifier = "==2025.12.19" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "interrogate", marker = "extra == 'dev'", specifier = "==1.7.0" }, + { name = "mypy", extras = ["faster-cache"], marker = "extra == 'dev'", specifier = "==2.3.0" }, + { name = "mypy-strict-kwargs", marker = "extra == 'dev'", specifier = "==2026.7.19.1" }, + { name = "no-defaults", marker = "extra == 'dev'", specifier = "==2.1.0" }, + { name = "numpy", specifier = ">=2.4.4" }, + { name = "opencv-contrib-python-headless", specifier = ">=5.0.0.93" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "prek", marker = "extra == 'dev'", specifier = "==0.4.13" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "pydocstringformatter", marker = "extra == 'dev'", specifier = "==1.0.0" }, + { name = "pydocstyle", marker = "extra == 'dev'", specifier = "==6.3" }, + { name = "pylint", extras = ["spelling"], marker = "extra == 'dev'", specifier = "==4.0.7" }, + { name = "pylint-per-file-ignores", marker = "extra == 'dev'", specifier = "==3.2.1" }, + { name = "pyproject-fmt", marker = "extra == 'dev'", specifier = "==2.27.0" }, + { name = "pyrefly", marker = "extra == 'dev'", specifier = "==1.2.0" }, + { name = "pyright", marker = "extra == 'dev'", specifier = "==1.1.411" }, + { name = "pyroma", marker = "extra == 'dev'", specifier = "==5.0.1" }, + { name = "pyteenybrisque", specifier = ">=0.1.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, + { name = "pytest-beartype-tests", marker = "extra == 'dev'", specifier = "==2026.4.26" }, + { name = "pytest-partition-check", marker = "extra == 'dev'", specifier = "==2026.8.10.1" }, + { name = "pytest-retry", marker = "extra == 'dev'", specifier = "==1.7.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = "==6.0.3" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "requests-mock-flask", marker = "extra == 'dev'", specifier = "==2026.4.2" }, + { name = "responses", specifier = ">=0.25.3" }, + { name = "respx", specifier = ">=0.21.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.2" }, + { name = "shellcheck-py", marker = "extra == 'dev'", specifier = "==0.11.0.1" }, + { name = "shfmt-py", marker = "extra == 'dev'", specifier = "==4.0.0" }, + { name = "sphinx", marker = "extra == 'dev'", specifier = "==9.1.0" }, + { name = "sphinx-copybutton", marker = "extra == 'dev'", specifier = "==0.5.2" }, + { name = "sphinx-lint", marker = "extra == 'dev'", specifier = "==1.0.2" }, + { name = "sphinx-paramlinks", marker = "extra == 'dev'", specifier = "==0.6" }, + { name = "sphinx-pyproject", marker = "extra == 'dev'", specifier = "==0.3.0" }, + { name = "sphinx-substitution-extensions", marker = "extra == 'dev'", specifier = "==2026.8.13" }, + { name = "sphinx-toolbox", marker = "extra == 'dev'", specifier = "==4.3.0" }, + { name = "sphinxcontrib-httpdomain", marker = "extra == 'dev'", specifier = "==2.0.0" }, + { name = "sphinxcontrib-spelling", marker = "extra == 'dev'", specifier = "==8.0.2" }, + { name = "sphinxcontrib-towncrier", marker = "extra == 'dev'", specifier = "==0.5.0a0" }, + { name = "strict-kwargs", marker = "extra == 'dev'", specifier = "==2026.7.24" }, + { name = "sybil", marker = "extra == 'dev'", specifier = "==10.1.0" }, + { name = "tenacity", marker = "extra == 'dev'", specifier = "==9.1.4" }, + { name = "towncrier", marker = "extra == 'dev'", specifier = "==25.8.0" }, + { name = "towncrier", marker = "extra == 'release'", specifier = "==25.8.0" }, + { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.70" }, + { name = "types-docker", marker = "extra == 'dev'", specifier = "==7.2.0.20260811" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.20260724" }, + { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.33.0.20260712" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "urllib3", marker = "extra == 'dev'", specifier = "==2.7.0" }, + { name = "vale", marker = "extra == 'dev'", specifier = "==3.13.0.0" }, + { name = "vulture", marker = "extra == 'dev'", specifier = "==2.16" }, + { name = "vws-auth-tools", specifier = ">=2024.7.12" }, + { name = "vws-python", marker = "extra == 'dev'", specifier = "==2026.2.25.1" }, + { name = "vws-test-fixtures", marker = "extra == 'dev'", specifier = "==2023.3.5" }, + { name = "vws-web-tools", marker = "extra == 'dev'", specifier = "==2026.8.7" }, + { name = "werkzeug", specifier = ">=3.1.2" }, + { name = "yamlfix", marker = "extra == 'dev'", specifier = "==1.19.1" }, + { name = "zizmor", marker = "extra == 'dev'", specifier = "==1.29.0" }, +] +provides-extras = ["dev", "release"] + +[package.metadata.requires-dev] +dev = [] + +[[package]] +name = "vws-test-fixtures" +version = "2023.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/f2/db267b21f32539d78aae06add3b31fa1e562a00a5b7d0aa1d0ee18aaadf9/vws-test-fixtures-2023.3.5.tar.gz", hash = "sha256:ba9baafb6fc8cd63338ee9c2b7c70876e6b33c6eb85edf7c4e8e88d9ab25467c", size = 61510, upload-time = "2023-03-05T17:26:04.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/40/e50b6c31637dfb41b13ffc41c799f210aa0fbb56853d32fee8cdfd8d9712/vws_test_fixtures-2023.3.5-py2.py3-none-any.whl", hash = "sha256:7f9f6a6be8e31bdd3ae4f290e7dea6637dc213a2da4c59d0cb6051c2054dee9f", size = 49364, upload-time = "2023-03-05T17:26:02.051Z" }, +] + +[[package]] +name = "vws-web-tools" +version = "2026.8.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "click" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "selenium" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/87/f5fb050d9fabb53dab9f7ea1458d9e03bc5aa404cad40ee769614f3596f3/vws_web_tools-2026.8.7.tar.gz", hash = "sha256:2e25a123b07cc3afb4edb653459b5a3fb13c8f62c37588f861c0adeaf50d2bdb", size = 49308, upload-time = "2026-08-07T22:30:35.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/58/003339fc706d870459e6b190e1bd81006cf810dd3cf9a187bc9481360287/vws_web_tools-2026.8.7-py3-none-any.whl", hash = "sha256:1574270c392c0d7558fe7527ec97f178c93ab27b347efd9ef590e6ab0ecf116e", size = 12749, upload-time = "2026-08-07T22:30:33.549Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wheel-filename" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/be/726dab762b770d0417e505c58e26d661aac1ec0c831e483cda4817ca2417/wheel_filename-1.4.2.tar.gz", hash = "sha256:87891c465dcbb40b40394a906f01a93214bdd51aa5d25e3a9a59cae62bc298fd", size = 7911, upload-time = "2024-12-01T13:03:16.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl", hash = "sha256:3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3", size = 6195, upload-time = "2024-12-01T13:03:00.536Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yamlfix" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "maison" }, + { name = "pydantic" }, + { name = "ruyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/1d/b60d4411ff495de9b7598cc041e29c661e8e2f9d476a8a09bad1f54c1bce/yamlfix-1.19.1.tar.gz", hash = "sha256:05f6add13959637564f278e9237f6e201ff75e061a0a4cb9fc06fa95c3001a22", size = 39483, upload-time = "2025-12-18T09:57:23.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/c7/cba5941b7066f59dbddfe88bdc7154edbe5119bacb3814599997fbc2acac/yamlfix-1.19.1-py3-none-any.whl", hash = "sha256:b885fcf171a2eb59df83c219355bb17dd147675645e2756754372c0bd0b80ea5", size = 28393, upload-time = "2025-12-18T09:57:21.547Z" }, +] + +[[package]] +name = "zizmor" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/f8/f4e3fc0b316d5241b6d6968e8fb702e28446bc7d3c1e2b229f4caa6eacf2/zizmor-1.29.0.tar.gz", hash = "sha256:60e34e83c67064e0036989c7c525d13413e897aa4c4f683f1efb2048cdb28a47", size = 571865, upload-time = "2026-08-01T21:09:19.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/97/667ef4db0ca9225ee402c1b947b5b6f17fd234d72c15a71db150b7695c62/zizmor-1.29.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ea72f84d610643d57f96430c655a3780d0b874e477d32e14eae8e910f6cce1fd", size = 9037504, upload-time = "2026-08-01T21:08:56.752Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d4/9fc7deaf75778e7516fa1d6c836377c3cb5d203dedc28899946b6f11ecdb/zizmor-1.29.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5aafe617d7b1e0c0c15d58fdf20495f360f74a791dfa136f76630b4cc06c2a34", size = 8654426, upload-time = "2026-08-01T21:08:59.212Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/6db714fb0aa08aeec62eb9d6ad6a443a1f4ed50d4c0b789944ae55fb83e4/zizmor-1.29.0-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:67644ae8d6d0394204b9a488f7d86f0dd66fe562f4ba85fc53e6105a6bfc7b6a", size = 8918927, upload-time = "2026-08-01T21:09:01.46Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/a12edc0c0c0a0101c54dbb9099ff08f8de2fcb35c36cb706db3deb2c2728/zizmor-1.29.0-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:81e4093fed5c8a41d6ae7bb773085a9d2e6c0b0a0b560d46a9c76d69be0a07ed", size = 8500655, upload-time = "2026-08-01T21:09:03.677Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/dfa67018b76bc4f2f50e265e8cbd1293833d1b1de5f3f02fbbb7487ae9c6/zizmor-1.29.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:587b99c2e1b34575c6c8565c2bfde415ca8bc0310f5589f19bc948c8dea10a20", size = 9351035, upload-time = "2026-08-01T21:09:06.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/1b/93cdd5a06984b394d90001f9778008a21689052904109080a09952626c99/zizmor-1.29.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:061600f23c46f2e400bcdef666c236de7e5c0b07dd6ca046daa001eb1514b909", size = 8941717, upload-time = "2026-08-01T21:09:08.861Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f4/8d9e54405b477bc8e4b56c1a60123fca26c109ea6a762eea104fab32555e/zizmor-1.29.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:332546480be38aca95c149f835e0dcb7679ab5d74618a90c6ccb3fa6b8c7b99d", size = 8468289, upload-time = "2026-08-01T21:09:11.096Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/06d57ca830cc4653369c5aca22cccbf04c8c36ee84a67f351214e556bad8/zizmor-1.29.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a7462b9ab45d72a20ad5ab8193b430df8184c59e2bf46954ddd09496f2f00b45", size = 9446505, upload-time = "2026-08-01T21:09:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/253d9a3538e0ea6f96a3bcd69839c0c5a816a00299620b58a10b5bd1df59/zizmor-1.29.0-py3-none-win32.whl", hash = "sha256:8c759e68cd866375030ca39e19e2de47a056b7be7288c1620e2d5b4c274f631f", size = 7655723, upload-time = "2026-08-01T21:09:15.758Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2d/7919bc23475273ed8038a031fc24bc3f2005c78e608ce8df96746ef0fb98/zizmor-1.29.0-py3-none-win_amd64.whl", hash = "sha256:0fb85948ba5ffc7a8116eee36fe9cfc10167225c97bd2810e3378e66a9fd27c4", size = 8785519, upload-time = "2026-08-01T21:09:17.513Z" }, +] diff --git a/vuforia_secrets.env.example b/vuforia_secrets.env.example index 7133b01f1..4b4284a2c 100644 --- a/vuforia_secrets.env.example +++ b/vuforia_secrets.env.example @@ -1,15 +1,31 @@ -VUFORIA_TARGET_MANAGER_DATABASE_NAME= +VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_database_name +VUFORIA_DATABASE_ID=example_database_id -VUFORIA_SERVER_ACCESS_KEY= -VUFORIA_SERVER_SECRET_KEY= +VUFORIA_SERVER_ACCESS_KEY=example_server_access_key +VUFORIA_SERVER_SECRET_KEY=example_server_secret_key -VUFORIA_CLIENT_ACCESS_KEY= -VUFORIA_CLIENT_SECRET_KEY= +VUFORIA_CLIENT_ACCESS_KEY=example_client_access_key +VUFORIA_CLIENT_SECRET_KEY=example_client_secret_key -INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME= +INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_inactive_database_name -INACTIVE_VUFORIA_SERVER_ACCESS_KEY= -INACTIVE_VUFORIA_SERVER_SECRET_KEY= +INACTIVE_VUFORIA_SERVER_ACCESS_KEY=example_inactive_server_access_key +INACTIVE_VUFORIA_SERVER_SECRET_KEY=example_inactive_server_secret_key -INACTIVE_VUFORIA_CLIENT_ACCESS_KEY= -INACTIVE_VUFORIA_CLIENT_SECRET_KEY= +INACTIVE_VUFORIA_CLIENT_ACCESS_KEY=example_inactive_client_access_key +INACTIVE_VUFORIA_CLIENT_SECRET_KEY=example_inactive_client_secret_key + +VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_vumark_database_name +VUMARK_VUFORIA_TARGET_ID=examplevumarktargetid + +VUMARK_VUFORIA_SERVER_ACCESS_KEY=example_vumark_server_access_key +VUMARK_VUFORIA_SERVER_SECRET_KEY=example_vumark_server_secret_key + +INACTIVE_VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_inactive_vumark_database_name + +INACTIVE_VUMARK_VUFORIA_SERVER_ACCESS_KEY=example_inactive_vumark_server_access_key +INACTIVE_VUMARK_VUFORIA_SERVER_SECRET_KEY=example_inactive_vumark_server_secret_key + +MODEL_TARGET_VUFORIA_CLIENT_ID=example_model_target_client_id +MODEL_TARGET_VUFORIA_CLIENT_SECRET=example_model_target_client_secret +MODEL_TARGET_VUFORIA_CAD_DATA_URL=https://example.com/model.glb diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 000000000..ce05fe5e8 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,10 @@ +--- +rules: + unpinned-uses: + disable: true + cache-poisoning: + disable: true + dependabot-cooldown: + disable: true + superfluous-actions: + disable: true