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