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 index 5a1e0fe0b..a2e641793 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,13 @@ +--- version: 2 + updates: -- package-ecosystem: pip - directory: "/requirements" - schedule: - interval: daily - open-pull-requests-limit: 10 + - package-ecosystem: pip + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 6dbf88932..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,166 +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: - fail-fast: false - matrix: - python-version: ["3.9"] - 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_one_part_no_space - - test_authorization_header.py::TestMalformed::test_one_part_with_space - - test_authorization_header.py::TestMalformed::test_missing_signature - - test_authorization_header.py::TestBadKey - - test_content_length.py::TestIncorrect::test_not_integer - - test_content_length.py::TestIncorrect::test_too_large - - test_content_length.py::TestIncorrect::test_too_small - - test_database_summary.py - - test_date_header.py::TestFormat - - test_date_header.py::TestMissing - - test_date_header.py::TestSkewedTime::test_date_out_of_range - - test_date_header.py::TestSkewedTime::test_date_in_range - - 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_image_valid - - test_update_target.py::TestImage::test_bad_image_format_or_color_space - - test_update_target.py::TestImage::test_corrupted - - test_update_target.py::TestImage::test_image_too_large - - test_update_target.py::TestImage::test_not_base64_encoded_processable - - test_update_target.py::TestImage::test_not_base64_encoded_not_processable - - test_update_target.py::TestImage::test_not_image - - test_update_target.py::TestImage::test_invalid_type - - test_update_target.py::TestImage::test_rating_can_change - - 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_requests_mock_usage.py - - test_flask_app_usage.py - - test_docker.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 - with: - # See https://github.com/codecov/codecov-action/issues/190. - fetch-depth: 2 - - - name: "Set up Python" - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - # This is like the example but we use ``*requirements.txt`` rather - # than ``requirements.txt`` because we have multiple requirements - # files. - key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - 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: "Show coverage file" - run: | - # Sometimes we have been sure that we have 100% coverage, but codecov - # says otherwise. - # - # We show the coverage file here to help with debugging. - # https://github.com/VWS-Python/vws-python-mock/issues/708 - cat ./coverage.xml - - # We run this job on every PR, on every merge to master, and nightly. - # This causes us to hit an issue with Codecov. - # - # We see "Too many uploads to this commit.". - # See https://community.codecov.io/t/too-many-uploads-to-this-commit/2574. - # - # To work around this, we do not upload coverage data on scheduled runs. - # We print the event name here to help with debugging. - - name: "Show event name" - run: | - echo ${{ github.event_name }} - - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v1" - with: - fail_ci_if_error: true - if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} 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 index be8c840a8..70797f6be 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,5 +1,4 @@ --- - name: Build Docker images # This matches the Docker image building done in the release process. @@ -8,13 +7,16 @@ name: Build Docker images 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: @@ -22,34 +24,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + with: + persist-credentials: false - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 - - name: Build target manager Docker image - uses: docker/build-push-action@v2.7.0 - with: - platforms: linux/amd64,linux/arm64 - file: src/mock_vws/_flask_server/dockerfiles/target_manager/Dockerfile - push: false - tags: | - adamtheturtle/vuforia-target-manager-mock:latest + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - - name: Build VWS Docker image - uses: docker/build-push-action@v2.7.0 + - name: Check Docker bake definition + uses: docker/bake-action@v7.1.0 with: - platforms: linux/amd64,linux/arm64 - file: src/mock_vws/_flask_server/dockerfiles/vws/Dockerfile - push: false - tags: | - adamtheturtle/vuforia-vws-mock:latest + call: check - - name: Build VWQ Docker image - uses: docker/build-push-action@v2.7.0 + - name: Build Docker images + uses: docker/bake-action@v7.1.0 with: - platforms: linux/amd64,linux/arm64 - file: src/mock_vws/_flask_server/dockerfiles/vwq/Dockerfile push: false - tags: | - adamtheturtle/vuforia-vwq-mock:latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e6a1dba78..bdac5ec0b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,52 +1,60 @@ --- - 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.9"] + 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@v6 with: - python-version: ${{ matrix.python-version }} + persist-credentials: false - - uses: actions/cache@v2 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - path: ~/.cache/pip - # This is like the example but we use ``*requirements.txt`` rather - # than ``requirements.txt`` because we have multiple requirements - # files. - key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - 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 + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Lint + # Use bash to ensure the step fails if any command fails. + # PowerShell does not fail on intermediate command failures by default. + shell: bash + run: uv run --extra=dev prek run --all-files --hook-stage ${{ matrix.hook-stage }} + --verbose + env: + UV_PYTHON: ${{ matrix.python-version }} + + - uses: pre-commit-ci/lite-action@v1.1.0 + if: always() + + 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 index 282b71e73..1bad745ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,115 +1,175 @@ --- - name: Release on: workflow_dispatch jobs: - build: - name: Publish a release + release: + name: Create release runs-on: ubuntu-latest + environment: release - strategy: - matrix: - python-version: ["3.10"] + permissions: + # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. + contents: write - steps: - - uses: actions/checkout@v2 + outputs: + version: ${{ steps.calver.outputs.release }} + tag: ${{ steps.tag_version.outputs.new_tag }} - - name: "Set up Python" - uses: actions/setup-python@v1 + steps: + - uses: actions/checkout@v6 with: - python-version: ${{ matrix.python-version }} - - - name: "Calver calculate version" + # 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: Calver calculate version uses: StephaneBour/actions-calver@master id: calver with: - date_format: "%Y.%m.%d" + date_format: '%Y.%m.%d' release: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: "Update changelog" - uses: jacobtomlinson/gha-find-replace@v2 + - name: Get the changelog underline + id: changelog_underline env: - NEXT_VERSION: ${{ steps.calver.outputs.release }} + RELEASE: ${{ steps.calver.outputs.release }} + run: | + underline="$(echo "$RELEASE" | tr -c '\n' '-')" + echo "underline=${underline}" >> "$GITHUB_OUTPUT" + + - name: Update changelog + id: update_changelog + uses: jacobtomlinson/gha-find-replace@v3 with: find: "Next\n----" - replace: "Next\n----\n\n${{ env.NEXT_VERSION }}\n------------" - include: "CHANGELOG.rst" + replace: | + Next + ---- + + ${{ steps.calver.outputs.release }} + ${{ steps.changelog_underline.outputs.underline }} + include: CHANGELOG.rst regex: false - - uses: stefanzweifel/git-auto-commit-action@v4 + - name: Check Update changelog was modified + env: + MODIFIED_FILES: ${{ steps.update_changelog.outputs.modifiedFiles }} + run: | + if [ "$MODIFIED_FILES" = "0" ]; then + echo "Error: No files were modified when updating changelog" + exit 1 + fi + - uses: stefanzweifel/git-auto-commit-action@v7 id: commit with: commit_message: Bump CHANGELOG + file_pattern: CHANGELOG.rst + # 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.0 + uses: mathieudutour/github-tag-action@v6.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} custom_tag: ${{ steps.calver.outputs.release }} - tag_prefix: "" + 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 }} body: ${{ steps.tag_version.outputs.changelog }} + 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@v6 + 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@v7 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + - name: Build a binary wheel and a source tarball run: | - # Checkout the latest tag - the one we just created. - git fetch --tags - git checkout $(git describe --tags $(git rev-list --tags --max-count=1)) - python -m pip install build - python -m build --sdist --wheel --outdir dist/ . + 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@master + uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.PYPI_API_TOKEN }} verbose: true - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + docker: + name: Publish Docker images + needs: release + runs-on: ubuntu-latest + environment: dockerhub - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + permissions: + packages: write - - name: Build and push target manager Docker image - uses: docker/build-push-action@v2.7.0 + steps: + - uses: actions/checkout@v6 with: - file: src/mock_vws/_flask_server/dockerfiles/target_manager/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: | - adamtheturtle/vuforia-target-manager-mock:latest - adamtheturtle/vuforia-target-manager-mock:${{ steps.calver.outputs.release }} + ref: ${{ needs.release.outputs.tag }} + persist-credentials: false - - name: Build and push VWS Docker image - uses: docker/build-push-action@v2.7.0 + - name: Login to GHCR + uses: docker/login-action@v4 with: - file: src/mock_vws/_flask_server/dockerfiles/vws/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: | - adamtheturtle/vuforia-vws-mock:latest - adamtheturtle/vuforia-vws-mock:${{ steps.calver.outputs.release }} + 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 VWQ Docker image - uses: docker/build-push-action@v2.7.0 + - name: Build and push Docker images + uses: docker/bake-action@v7.1.0 with: - file: src/mock_vws/_flask_server/dockerfiles/vwq/Dockerfile - platforms: linux/amd64,linux/arm64 push: true - tags: | - adamtheturtle/vuforia-vwq-mock:latest - adamtheturtle/vuforia-vwq-mock:${{ steps.calver.outputs.release }} + env: + VERSION: ${{ needs.release.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..3b7938570 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,345 @@ +--- +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::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_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_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_vumark_generation_api.py + - tests/mock_vws/test_target_validators.py + - tests/mock_vws/test_docker.py + - ci/test_custom_linters.py + - README.rst + - docs/ + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v7 + 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@v6 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v7 + 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@v6 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v7 + 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 + shell: bash + run: | + # We use pytest-xdist to make this run much faster. + # The downside is that we cannot use -s / --capture=no. + # + # We use coverage to collect coverage data but we currently + # do not upload / use it because combining Windows and Linux + # coverage is challenging. + # + # We therefore have a few ``# pragma: no cover`` statements. + uv run --extra=dev \ + coverage run -m pytest \ + --skip-real \ + -vvv \ + --exitfirst \ + -n auto \ + . + env: + UV_PYTHON: ${{ matrix.python-version }} + + coverage: + name: Combine & check coverage + needs: [ci-tests, skip-tests, windows-tests] + if: always() + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v7 + 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 tool install 'coverage[toml]' + + coverage combine + coverage html --skip-covered --skip-empty + + # Report and write to summary. + coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" + + # Report again and fail if under 100%. + 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/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml deleted file mode 100644 index 47a477137..000000000 --- a/.github/workflows/windows-ci.yml +++ /dev/null @@ -1,89 +0,0 @@ ---- - -name: Windows 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: - - strategy: - matrix: - python-version: ["3.9"] - platform: [windows-latest] - - runs-on: ${{ matrix.platform }} - - steps: - - uses: actions/checkout@v2 - with: - # See https://github.com/codecov/codecov-action/issues/190. - fetch-depth: 2 - - - name: "Set up Python" - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - uses: actions/cache@v2 - with: - path: ~/.cache/pip - # This is like the example but we use ``*requirements.txt`` rather - # than ``requirements.txt`` because we have multiple requirements - # files. - key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - 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: | - cp ./vuforia_secrets.env.example ./vuforia_secrets.env - - - name: "Run tests" - env: - SKIP_REAL: 1 - run: | - pytest -s -vvv --exitfirst --cov=src/ --cov=tests --cov-report=xml tests/mock_vws/ - - - name: "Show coverage file" - run: | - # Sometimes we have been sure that we have 100% coverage, but codecov - # says otherwise. - # - # We show the coverage file here to help with debugging. - # https://github.com/VWS-Python/vws-python-mock/issues/708 - cat ./coverage.xml - - # We run this job on every PR, on every merge to master, and nightly. - # This causes us to hit an issue with Codecov. - # - # We see "Too many uploads to this commit.". - # See https://community.codecov.io/t/too-many-uploads-to-this-commit/2574. - # - # To work around this, we do not upload coverage data on scheduled runs. - # We print the event name here to help with debugging. - - name: "Show event name" - run: | - echo ${{ github.event_name }} - - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v1" - with: - fail_ci_if_error: true - if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} diff --git a/.gitignore b/.gitignore index ec1645423..a008c6937 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,7 @@ secrets.tar # setuptools_scm src/*/_setuptools_scm_version.txt + +uv.lock + +.claude/scheduled_tasks.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..b6a7e663f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,459 @@ +--- +fail_fast: true + +.uv_version: &uv_version uv==0.11.7 + +# We use system Python, with required dependencies specified in pyproject.toml. +# We therefore cannot use those dependencies in pre-commit CI. +ci: + skip: + - actionlint + - sphinx-lint + - check-manifest + - custom-linters + - deptry + - doc8 + - docs + - interrogate + - interrogate-docs + - linkcheck + - mypy + - mypy-docs + - pylint + - pyproject-fmt-fix + - pyright + - pyright-docs + - pyright-verifytypes + - ty + - ty-docs + - pyroma + - ruff-check-fix + - ruff-check-fix-docs + - ruff-format-fix + - ruff-format-fix-docs + - pydocstringformatter + - shellcheck + - shellcheck-docs + - shfmt + - shfmt-docs + - spelling + - vulture + - vulture-docs + - yamlfix + - zizmor + - pyrefly + - pyrefly-docs + +# 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.14.0 + hooks: + - id: hadolint + + stages: [pre-commit] + - repo: local + hooks: + - id: custom-linters + name: custom-linters + entry: uv run --extra=dev -m pytest ci/test_custom_linters.py + 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: 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 + 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" + 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: 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: pyproject-fmt + 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/.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 456b909e8..5578d136b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,92 +4,101 @@ Changelog Next ---- -2021.12.27 ------------- +2026.04.26 +---------- -2021.12.26.8 ------------- -2021.12.26.7 +2026.02.22.3 ------------ -2021.12.26.6 ------------- -2021.12.26.5 ------------- +- ``MockVWS`` now intercepts both ``requests`` (via ``responses``) and ``httpx`` (via ``respx``) simultaneously. + ``MockVWSForHttpx`` has been removed — ``MockVWS`` handles both HTTP libraries. -2021.12.26.4 +2026.02.22.2 ------------ -2021.12.26.3 ------------- -2021.12.26.2 +2026.02.22.1 ------------ -2021.12.26 ------------- -2021.03.27.1 ------------- +2026.02.22 +---------- -2021.03.27.0 ------------- -2020.10.03.0 ------------- +2026.02.21 +---------- -2020.09.25.0 ------------- -2019.12.27.0 ------------- +- 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``. -2019.12.17.0 +2026.02.18.2 ------------ -2019.12.07.1 ------------- -2019.12.07.0 +2026.02.18.1 ------------ -2019.09.28.0 ------------- -2018.12.01.0 ------------- +2026.02.18 +---------- -- Distribute type information. -2018.11.25.0 +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 9b3a8ba9e..e69de29bb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +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 pyproject.toml diff --git a/Makefile b/Makefile deleted file mode 100644 index f5f941ebd..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 \ - spelling \ - vulture \ - pylint \ - pydocstyle \ - custom-linters \ - -.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 7f1162806..f49e6924f 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|Build Status| |codecov| |PyPI| |Documentation Status| +|Build Status| |PyPI| VWS Mock ======== @@ -8,31 +8,53 @@ VWS Mock Mock for the Vuforia Web Services (VWS) API and the Vuforia Web Query API. -Mocking calls made to Vuforia with Python ``requests`` ------------------------------------------------------- +Mocking calls made to Vuforia +------------------------------ -Using the mock redirects requests to Vuforia made with `requests`_ to an in-memory implementation. +``MockVWS`` intercepts requests made with `requests`_ or `httpx`_. -This requires Python 3.9+. - -.. code:: sh +.. code-block:: shell pip install vws-python-mock -.. code:: python +This requires Python |minimum-python-version|\+. + +.. 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 = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + # This will use the Vuforia mock. + 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 = 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') + 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. .. _requests: https://pypi.org/project/requests/ +.. _httpx: https://pypi.org/project/httpx/ Using Docker to mock calls to Vuforia from any language ------------------------------------------------------- @@ -41,21 +63,17 @@ 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. +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 new file mode 100644 index 000000000..1a76e35be --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1 @@ +"""Admin tools.""" diff --git a/admin/create_secrets_files.py b/admin/create_secrets_files.py new file mode 100644 index 000000000..a3fdfaf5f --- /dev/null +++ b/admin/create_secrets_files.py @@ -0,0 +1,301 @@ +"""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, 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, +) -> str: + """Generate the content of a secrets file.""" + return textwrap.dedent( + text=f"""\ + VUFORIA_TARGET_MANAGER_DATABASE_NAME={cloud_database_details["database_name"]} + 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"]} + """, + ) + + +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 _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() + + 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, + ) + 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/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 285ce511d..000000000 --- a/ci/custom_linters.py +++ /dev/null @@ -1,99 +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 configuration 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', '-q', '--collect-only', ci_pattern] - result = subprocess.run(args=args, stdout=subprocess.PIPE, check=True) - for line in result.stdout.decode().splitlines(): - if line and 'collected in' not in line: - tests.add(line) - return tests - - -def test_ci_patterns_valid() -> None: - """ - All of the CI patterns in the CI configuration 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] = {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 11ea75732..000000000 --- a/ci/set_secrets_file.py +++ /dev/null @@ -1,31 +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' - print(f'Using {secrets_path}') - shutil.copy(secrets_path, './vuforia_secrets.env') - - -if __name__ == '__main__': - move_secrets_file() diff --git a/ci/test_custom_linters.py b/ci/test_custom_linters.py new file mode 100644 index 000000000..3ba81680b --- /dev/null +++ b/ci/test_custom_linters.py @@ -0,0 +1,133 @@ +"""Custom lint tests.""" + +from pathlib import Path + +import pytest +import yaml +from beartype import beartype + + +@beartype +def _ci_patterns(*, repository_root: Path) -> set[str]: + """Return the CI patterns given in the CI configuration file.""" + ci_file = repository_root / ".github" / "workflows" / "test.yml" + github_workflow_config = yaml.safe_load(stream=ci_file.read_text()) + matrix = github_workflow_config["jobs"]["ci-tests"]["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 + + +class _CollectPlugin: + """Pytest plugin that records the node IDs of collected items.""" + + def __init__(self) -> None: + """Start with an empty set of collected node IDs.""" + self.collected: set[str] = set() + + def pytest_itemcollected(self, item: pytest.Item) -> None: + """Record each collected item's node ID.""" + self.collected.add(item.nodeid) + + +@beartype +def _tests_from_pattern(*, ci_pattern: str) -> set[str]: + """From a CI pattern, get all tests ``pytest`` would collect.""" + plugin = _CollectPlugin() + pytest.main( + args=[ + "-q", + "--collect-only", + # Disable pytest-retry to avoid: + # ``` + # ValueError: no option named 'filtered_exceptions' + # ``` + # which causes the nested run to exit with INTERNAL_ERROR + # before any items are collected. + "-p", + "no:pytest-retry", + # Disable pytest-beartype-tests to avoid + # https://github.com/beartype/beartype/issues/637 — wrapping + # collected items with @beartype installs a buggy + # __annotate_beartype__ closure on the underlying test + # function, which crashes a subsequent nested collection on + # Python 3.14. + "-p", + "no:pytest_beartype_tests", + # Disable warnings to avoid many instances of: + # ``` + # Unknown config option: retry_delay + # ``` + "--disable-warnings", + ci_pattern, + ], + plugins=[plugin], + ) + return plugin.collected + + +def test_ci_patterns_valid(request: pytest.FixtureRequest) -> None: + """ + All of the CI patterns in the CI configuration match at least one + test in + the test suite. + """ + ci_patterns = _ci_patterns(repository_root=request.config.rootpath) + + for ci_pattern in ci_patterns: + collect_only_result = pytest.main( + args=[ + "--collect-only", + ci_pattern, + # Disable pytest-retry to avoid: + # ``` + # ValueError: no option named 'filtered_exceptions' + # ```` + "-p", + "no:pytest-retry", + # Disable pytest-beartype-tests to avoid + # https://github.com/beartype/beartype/issues/637 — + # wrapping collected items with @beartype installs a + # buggy __annotate_beartype__ closure on the underlying + # test function, which crashes a subsequent nested + # collection on Python 3.14. + "-p", + "no:pytest_beartype_tests", + # Disable warnings to avoid many instances of: + # ``` + # Unknown config option: retry_delay + # ``` + "--disable-warnings", + ], + ) + + message = f'"{ci_pattern}" does not match any tests.' + assert collect_only_result == 0, message + + +def test_tests_collected_once(request: pytest.FixtureRequest) -> 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(repository_root=request.config.rootpath) + all_tests = _tests_from_pattern(ci_pattern=".") + assert all_tests + tests_to_patterns: dict[str, set[str]] = {} + + for pattern in ci_patterns: + tests = _tests_from_pattern(ci_pattern=pattern) + for test in tests: + tests_to_patterns.setdefault(test, set()).add(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 + + assert tests_to_patterns.keys() - all_tests == set() + assert all_tests - tests_to_patterns.keys() == set() 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..72eedd78c --- /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 RETRY_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 RETRY_EXCEPTIONS 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 440e06ee1..c6829a3b4 100644 --- a/docs/source/basic-example.rst +++ b/docs/source/basic-example.rst @@ -1,19 +1,23 @@ -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 426036201..760f0dde1 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,98 +1,95 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Configuration for Sphinx. -""" +"""Configuration for Sphinx.""" -# pylint: disable=invalid-name +import importlib.metadata +from pathlib import Path -import datetime +from packaging.specifiers import SpecifierSet +from sphinx_pyproject import SphinxConfig -from pkg_resources import get_distribution +_pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" +_pyproject_config = SphinxConfig( + pyproject_file=_pyproject_file, + config_overrides={"version": None}, +) -project = 'VWS-Python-Mock' -author = 'Adam Dangoor' +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', - 'sphinxcontrib.autohttp.flask', + "sphinx_copybutton", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx_paramlinks", + "sphinx_substitution_extensions", + "sphinxcontrib.spelling", + "sphinxcontrib.autohttp.flask", + "sphinx_toolbox.more_autodoc.autoprotocol", ] -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' +# 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 -year = datetime.datetime.now().year -project_copyright = f'{year}, {author}' +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" -# 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}' +project_copyright = f"%Y, {author}" -language = None +# 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" -# The name of the syntax highlighting style to use. -pygments_style = 'sphinx' +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 -python_minumum_supported_version = '3.9' +language = "en" + +# The name of the syntax highlighting style to use. +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': ( - f'https://docs.python.org/{python_minumum_supported_version}', - 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', 'requests.exceptions.MissingSchema'), - ('http:obj', 'string'), -] -html_theme = 'furo' +html_theme = "furo" html_title = project html_show_copyright = False html_show_sphinx = False html_show_sourcelink = False html_theme_options = { - 'sidebar_hide_name': False, + "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""" -.. |python-minumum-version| replace:: {python_minumum_supported_version} .. |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 6b9b6b049..a902f2af8 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -1,5 +1,5 @@ -Contributing -============ +Contributing to |project| +========================= Contributions to this repository must pass tests and linting. @@ -10,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 @@ -49,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`. @@ -59,9 +61,9 @@ See :ref:`connecting-to-vuforia`. Then run ``pytest``: -.. prompt:: bash +.. code-block:: console - pytest + $ pytest .. _connecting-to-vuforia: @@ -82,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 ------------- @@ -104,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 ---------------------- @@ -132,13 +166,13 @@ The database summary from ``GET /summary`` has multiple undocumented return fiel The database summary from ``GET /summary`` is not immediately accurate. -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. @@ -147,10 +181,10 @@ 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. The ``request_count`` in a database summary is always ``0``. @@ -158,7 +192,7 @@ 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 1704c35a4..1f4876ea9 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,19 @@ 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. - -Matching targets in the processing state ----------------------------------------- - -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. +Matching recently deleted targets +--------------------------------- -Matching 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 --------------------------------------- @@ -91,18 +86,31 @@ 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`` +* ``ProjectSuspended`` +* ``RequestQuotaReached`` +* ``TargetQuotaReached`` +* ``TooManyRequests`` ``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. + +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 index 160bbd21f..ab5c2220c 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -20,27 +20,27 @@ The VWS and VWQ containers must point to the target manager container using the Creating containers ^^^^^^^^^^^^^^^^^^^ -.. prompt:: bash +.. code-block:: console - docker network create -d bridge vws-bridge-network - docker run \ + $ docker network create -d bridge vws-bridge-network + $ docker run \ --detach \ --publish 5005:5000 \ --name vuforia-target-manager-mock \ --network vws-bridge-network \ - adamtheturtle/vuforia-target-manager-mock - docker run \ + ghcr.io/vws-python/vuforia-target-manager-mock + $ docker run \ --detach \ --publish 5006:5000 \ -e TARGET_MANAGER_BACKEND=vuforia-target-manager-mock:5000 \ --network vws-bridge-network \ - adamtheturtle/vuforia-vws-mock - docker run \ + ghcr.io/vws-python/vuforia-vws-mock + $ docker run \ --detach \ --publish 5007:5000 \ -e TARGET_MANAGER_BACKEND=vuforia-target-manager-mock:5000 \ --network vws-bridge-network \ - adamtheturtle/vuforia-vwq-mock + ghcr.io/vws-python/vuforia-vwq-mock Adding a database to the mock target manager @@ -54,16 +54,16 @@ To mimic this functionality, this mock provides a target manager container which 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_database + :endpoints: create_cloud_database For example, with the containers set up as in :ref:`creating-containers`, use ``curl``: -.. prompt:: bash $ auto +.. code-block:: console $ curl --request POST \ - --header "Content-Type: application/json" \ - --data '{}' \ - '127.0.0.1:5005/databases' + --header "Content-Type: application/json" \ + --data '{}' \ + '127.0.0.1:5005/cloud_databases' { "client_access_key": "2d61c1d17bb94694bee77c1f1f41e5d9", "client_secret_key": "b73f8170cf7d42728fa8ce66221ad147", @@ -80,7 +80,7 @@ Deleting a database To delete a database use the following endpoint: .. autoflask:: mock_vws._flask_server.target_manager:TARGET_MANAGER_FLASK_APP - :endpoints: delete_database + :endpoints: delete_cloud_database .. _Target Manager: https://developer.vuforia.com/target-manager @@ -100,22 +100,34 @@ Required configuration Optional configuration ^^^^^^^^^^^^^^^^^^^^^^ +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:: DELETION_PROCESSING_SECONDS +.. envvar:: QUERY_IMAGE_MATCHER - The number of seconds after a target deletion is recognized that the - query endpoint will return a 500 response on a match. + The matcher to use for the query endpoint. - Default 3.0 + Options include: -.. envvar:: DELETION_RECOGNITION_SECONDS + * ``exact``: The images must be exactly the same to match. + * ``structural_similarity``: The images must have a similar structural similarity to match. - The number of seconds after a target has been deleted that the query - endpoint will still recognize the target for. - - Default 0.2 + Default: ``structural_similarity`` VWS container ~~~~~~~~~~~~~ @@ -124,24 +136,31 @@ VWS container The number of seconds to process each image for. - Default 0.5 + Default: ``2.0`` + +.. 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 ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. prompt:: bash +.. code-block:: console - export REPOSITORY_ROOT=$PWD - export DOCKERFILE_DIR=$REPOSITORY_ROOT/src/mock_vws/_flask_server/dockerfiles - export BASE_DOCKERFILE=$DOCKERFILE_DIR/base/Dockerfile - export TARGET_MANAGER_DOCKERFILE=$DOCKERFILE_DIR/target_manager/Dockerfile - export VWS_DOCKERFILE=$DOCKERFILE_DIR/vws/Dockerfile - export VWQ_DOCKERFILE=$DOCKERFILE_DIR/vwq/Dockerfile + $ export REPOSITORY_ROOT="$PWD" + $ export DOCKERFILE="$REPOSITORY_ROOT/src/mock_vws/_flask_server/Dockerfile" - export TARGET_MANAGER_TAG=adamtheturtle/vuforia-target-manager-mock:latest - export VWS_TAG=adamtheturtle/vuforia-vws-mock:latest - export VWQ_TAG=adamtheturtle/vuforia-vwq-mock:latest + $ 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 $TARGET_MANAGER_DOCKERFILE --tag $TARGET_MANAGER_TAG - docker buildx build $REPOSITORY_ROOT --file $VWS_DOCKERFILE --tag $VWS_TAG - docker buildx build $REPOSITORY_ROOT --file $VWQ_DOCKERFILE --tag $VWQ_TAG + $ 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 f2729c536..04e81ebfe 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,17 +1,19 @@ |project| ========= -Mocking calls made to Vuforia with Python ``requests`` ------------------------------------------------------- +Mocking calls made to Vuforia +------------------------------ -.. prompt:: bash +.. code-block:: console - pip3 install vws-python-mock + $ pip install vws-python-mock -This requires Python |python-minumum-version|\+. +This requires Python |minimum-python-version|\+. .. include:: basic-example.rst +.. include:: httpx-example.rst + Using Docker to mock calls to Vuforia from any language ------------------------------------------------------- diff --git a/docs/source/installation.rst b/docs/source/installation.rst index ba2fe99c5..753d68829 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -1,8 +1,34 @@ Installation ------------ -.. prompt:: bash +.. code-block:: console - pip3 install vws-python-mock + $ pip install vws-python-mock -This requires Python |python-minumum-version|\+. +This requires Python |minimum-python-version|\+. + +Faster installation +~~~~~~~~~~~~~~~~~~~ + +This package depends on `PyTorch`_, which pip installs from PyPI as a large CUDA-enabled build (~873 MB) even on CPU-only machines. +To get a much smaller CPU-only build (~200 MB, no CUDA dependencies), install ``torch`` and ``torchvision`` from PyTorch's CPU index before installing this package: + +.. code-block:: console + + $ pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu + $ pip install vws-python-mock + +If you manage dependencies with ``uv``, add the following to your ``pyproject.toml`` instead: + +.. code-block:: toml + + [[tool.uv.index]] + name = "pytorch-cpu" + url = "https://download.pytorch.org/whl/cpu" + explicit = true + + [tool.uv.sources] + torch = { index = "pytorch-cpu" } + torchvision = { index = "pytorch-cpu" } + +.. _PyTorch: https://pytorch.org diff --git a/docs/source/mock-api-reference.rst b/docs/source/mock-api-reference.rst index c1ee30071..19a6185c4 100644 --- a/docs/source/mock-api-reference.rst +++ b/docs/source/mock-api-reference.rst @@ -7,24 +7,52 @@ API Reference :members: :undoc-members: -.. TODO why does this error only with :undoc-members: - -.. autoclass:: mock_vws.target.TargetDict +.. autoclass:: mock_vws.MissingSchemeError :members: :undoc-members: -.. autoclass:: mock_vws.target.Target +.. 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.states.States +.. autoclass:: mock_vws.database.VuMarkDatabase :members: :undoc-members: + :exclude-members: to_dict, from_dict, not_deleted_targets -.. autoclass:: mock_vws.database.DatabaseDict +.. 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 a9c773fe4..db1744feb 100644 --- a/docs/source/release-process.rst +++ b/docs/source/release-process.rst @@ -14,9 +14,9 @@ Perform a Release #. Perform a release: - .. prompt:: bash + .. code-block:: console :substitutions: - $ gh workflow run release.yml --repo |github-owner|/|github-repository| + $ gh workflow run release.yml --repo "|github-owner|/|github-repository|" -.. _Install GitHub CLI: https://cli.github.com/manual/installation +.. _Install GitHub CLI: https://cli.github.com/ diff --git a/lint.mk b/lint.mk deleted file mode 100644 index edfa243c0..000000000 --- a/lint.mk +++ /dev/null @@ -1,84 +0,0 @@ -# Make commands for linting - -SHELL := /bin/bash -euxo pipefail - -.PHONY: custom-linters -custom-linters: - # Running pytest needs this file - touch vuforia_secrets.env - pytest ci/custom_linters.py - -.PHONY: black -black: - black --check . - -.PHONY: fix-black -fix-black: - black . - -.PHONY: mypy -mypy: - mypy . - -.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 --skip-incompatible --requirements-file=requirements/requirements.txt src/ - -.PHONY: pip-missing-reqs -pip-missing-reqs: - pip-missing-reqs --requirements-file=requirements/requirements.txt src/ - -.PHONY: pylint -pylint: - pylint *.py src/ tests/ docs/ ci/ - -.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: 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/pyproject.toml b/pyproject.toml index e808d33bc..d496ab87d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,174 +1,411 @@ -[tool.pylint] +[build-system] +build-backend = "setuptools.build_meta" +requires = [ + "setuptools", + "setuptools-scm>=8.1.0", +] - [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 = true - - [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', - # Style issues that we can deal with ourselves - 'too-few-public-methods', - 'too-many-ancestors', - 'too-many-locals', - 'too-many-arguments', - 'too-many-instance-attributes', - 'too-many-return-statements', - 'too-many-lines', - 'too-many-statements', - '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] +[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>=1.26.4", + "pillow>=11.0.0", + "piq>=0.8.0", + "pydantic-settings>=2.6.1", + "requests>=2.32.3", + "responses>=0.25.3", + "respx>=0.21.0", + "torch>=2.5.1", + "torchmetrics>=1.5.1", + "torchvision>=0.20.1", + "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.13.5", + "deptry==0.25.1", + "dirty-equals==0.11", + "doc8==2.0.0", + "doccmd==2026.3.26.2", + "docker==7.1.0", + "freezegun==1.5.5", + "furo==2025.12.19", + "interrogate==1.7.0", + "mypy[faster-cache]==1.20.2", + "mypy-strict-kwargs==2026.1.12", + "prek==0.3.10", + "pydocstringformatter==0.7.5", + "pydocstyle==6.3", + "pylint[spelling]==4.0.5", + "pylint-per-file-ignores==3.2.1", + "pyproject-fmt==2.21.1", + "pyrefly==0.62.0", + "pyright==1.1.409", + "pyroma==5.0.1", + "pytest==9.0.3", + "pytest-beartype-tests==2026.4.26", + "pytest-retry==1.7.0", + "pytest-xdist==3.8.0", + "pyyaml==6.0.3", + "requests-mock-flask==2026.4.2", + "ruff==0.15.11", + # 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==3.12.0.2", + "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.1.12", + "sphinx-toolbox==4.2.0rc1", + "sphinxcontrib-httpdomain==2.0.0", + "sphinxcontrib-spelling==8.0.2", + "sybil==10.0.1", + "tenacity==9.1.4", + "ty==0.0.32", + "types-docker==7.1.0.20260409", + "types-pyyaml==6.0.12.20260408", + "types-requests==2.33.0.20260408", + "urllib3==2.6.3", + "vulture==2.16", + "vws-python==2026.2.25.1", + "vws-test-fixtures==2023.3.5", + "vws-web-tools==2026.2.22.1", + "yamlfix==1.19.1", + "zizmor==1.24.1", +] +optional-dependencies.release = [ "check-wheel-contents==0.6.3" ] +urls.Documentation = "https://vws-python.github.io/vws-python-mock/" +urls.Source = "https://github.com/VWS-Python/vws-python-mock" -line-length = 79 -skip-string-normalization = true +[dependency-groups] +dev = [] -[tool.isort] +[tool.setuptools] +zip-safe = false +package-data.mock_vws = [ + "py.typed", +] +packages.find.where = [ + "src", +] -multi_line_output = 3 -include_trailing_comma = true +[tool.distutils] +bdist_wheel.universal = true -[tool.coverage.run] +[tool.setuptools_scm] +# 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" +# 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" -branch = true +[tool.uv] +sources.torch = { index = "pytorch-cpu" } +sources.torchvision = { index = "pytorch-cpu" } +index = [ { name = "pytorch-cpu", url = "https://download.pytorch.org/whl/cpu", explicit = true } ] -[tool.pytest.ini_options] +[tool.ruff] +line-length = 79 +lint.select = [ + "ALL", +] +lint.ignore = [ + # Ruff warns that this conflicts with the formatter. + "COM812", + # 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."ci/test_custom_linters.py" = [ + # Allow asserts in tests. + "S101", +] +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.pydocstyle.convention = "google" -xfail_strict = true -log_cli = true -env_files = ["./vuforia_secrets.env"] +[tool.pylint] +# 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 +# Pickle collected data for later comparisons. +MASTER.persistent = true +# 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_per_file_ignores", + "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", +] +# 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", +] +# 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", +] +# 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 = [ + # Style issues that we can deal with ourselves + "too-few-public-methods", + "too-many-locals", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "locally-disabled", + # Let ruff handle long lines + "line-too-long", + # Let ruff handle unused imports + "unused-import", + # Let ruff deal with sorting + "ungrouped-imports", + # We don't need everything to be documented because of mypy + "missing-type-doc", + "missing-return-type-doc", + # Too difficult to please + "duplicate-code", + # Let ruff handle imports + "wrong-import-order", +] +# 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.check-manifest] - ignore = [ - "*.enc", - ".appveyor.yml", - ".coveragerc", - ".isort.cfg", - ".markdownlint.json", - ".pydocstyle", - ".remarkrc", - ".readthedocs.yml", - "readthedocs.yaml", - ".style.yapf", - ".travis.yml", - "CHANGELOG.rst", - "CODE_OF_CONDUCT.rst", - "CONTRIBUTING.rst", - "LICENSE", - "Makefile", - "ci", - "ci/**", - "codecov.yaml", - "doc8.ini", - "docs", - "docs/**", - ".git_archival.txt", - "mypy.ini", - "pylintrc", - "pytest.ini", - "spelling_private_dict.txt", - "tests", - "tests-pylintrc", - "tests/**", - "vuforia_secrets.env.example", - "lint.mk", - "src/mock_vws/_flask_server/dockerfiles/*/Dockerfile", - "secrets.tar.gpg", + ".checkmake-config.ini", + ".prettierrc", + ".yamlfmt", + "*.enc", + "admin/**", + "CHANGELOG.rst", + "CODE_OF_CONDUCT.rst", + "CONTRIBUTING.rst", + "LICENSE", + "Makefile", + "ci", + "ci/**", + "docs", + "docs/**", + ".git_archival.txt", + "spelling_private_dict.txt", + "tests", + "tests/**", + "vuforia_secrets.env.example", + "lint.mk", + "src/mock_vws/_flask_server/Dockerfile", + "secrets.tar.gpg", +] + +[tool.deptry] +optional_dependencies_dev_groups = [ + "dev", + "release", +] +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", + # torchvision is used transitively via piq, but must be a direct dependency + # so that tool.uv.sources can route it to the CPU-only PyTorch index. + "torchvision", ] +[tool.pyproject-fmt] +indent = 4 +keep_full_version = true +max_supported_python = "3.14" + [tool.mypy] +strict = true +files = [ "." ] +exclude = [ "build" ] +plugins = [ + "pydantic.mypy", + "mypy_strict_kwargs", +] +follow_untyped_imports = true -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -follow_imports = "normal" -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.pyrefly] +search_path = [ + ".", + "src", +] +errors.non-exhaustive-match = "error" -[tool.doc8] +[tool.pyright] +enableTypeIgnoreComments = false +reportUnnecessaryTypeIgnoreComment = true +typeCheckingMode = "strict" + +[tool.pytest] +xfail_strict = true +log_cli = true +addopts = [ + "--strict-markers", +] +markers = [ + "requires_docker_build", +] +# Options for pytest-retry. +retries = "10" +retry_delay = "10" +cumulative_timing = false + +[tool.coverage] +run.branch = true +run.omit = [ + "src/mock_vws/_flask_server/healthcheck.py", +] +run.parallel = true +run.source = [ "ci/", "src/", "tests/" ] +report.exclude_also = [ + "class .*\\bProtocol\\):", + "if TYPE_CHECKING:", +] +report.fail_under = 100 +report.show_missing = true + +[tool.pydocstringformatter] +write = true +split-summary-body = false +max-line-length = 75 +linewrap-full-docstring = true + +[tool.interrogate] +fail-under = 100 +omit-covered-files = true +verbose = 2 + +[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", @@ -179,33 +416,69 @@ ignore_path = [ "./src/*/_setuptools_scm_version.txt", ] -[tool.setuptools_scm] - -# 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.pydocstyle] -# We do not have summary lines, care about "mood", or need sections with -# dash underlined titles. -ignore = [ - 'D200', - 'D205', - 'D400', - 'D415', - 'D202', - 'D203', - 'D212', - 'D401', - 'D406', - 'D407', - 'D413', +[tool.vulture] +# Ideally we would limit the paths to the source code where we want to ignore names, +# but Vulture does not enable this. +ignore_names = [ + # pytest configuration + "pytest_collect_file", + "pytest_collection_modifyitems", + "pytest_itemcollected", + "pytest_plugins", + "pytest_set_filtered_exceptions", + "pytest_addoption", + # pytest fixtures - we name fixtures like this for this purpose + "fixture_*", + # Sphinx + "autoclass_content", + "autoclass_content", + "autodoc_member_order", + "autodoc_use_legacy_class_based", + "copybutton_exclude", + "extensions", + "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", + "nitpicky", + "project_copyright", + "pygments_style", + "rst_prolog", + "source_suffix", + "spelling_word_list_filename", + "templates_path", + "warning_is_error", + # Too difficult to test (see notes in the code) + "DATE_RANGE_ERROR", + "REQUEST_QUOTA_REACHED", + # pydantic-settings + "model_config", + # Used in TYPE_CHECKING for type hints + "CloudDatabaseDict", + "DatabaseDict", + "VuMarkDatabaseDict", + "VuMarkTargetDict", +] +# Duplicate some of .gitignore +exclude = [ ".venv" ] +ignore_decorators = [ + "@pytest.fixture", + "@route", + # Flask + "@*APP.route", + "@*APP.after_request", + "@*APP.before_request", + "@*APP.errorhandler", ] -[build-system] -requires = ["setuptools", "pip", "wheel"] -build-backend = "setuptools.build_meta" +[tool.yamlfix] +section_whitelines = 1 +whitelines = 1 diff --git a/readthedocs.yaml b/readthedocs.yaml deleted file mode 100644 index aa4ff368d..000000000 --- a/readthedocs.yaml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 - -build: - os: ubuntu-20.04 - tools: - python: "3.9" - -python: - install: - - method: pip - path: . - extra_requirements: - - dev - -sphinx: - builder: html - fail_on_warning: true diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt deleted file mode 100644 index 17798f89a..000000000 --- a/requirements/dev-requirements.txt +++ /dev/null @@ -1,38 +0,0 @@ -PyYAML==6.0 -Sphinx-Substitution-Extensions==2020.9.30.0 -Sphinx==4.3.2 -VWS-Test-Fixtures==2021.11.5.1 -autoflake==1.4 -black==21.12b0 -check-manifest==0.47 -doc8==0.10.1 -docker==5.0.3 -dodgy==0.2.1 # Look for uploaded secrets -flake8-commas==2.1.0 # Require silicon valley commas -flake8-quotes==3.3.1 # Require single quotes -flake8==4.0.1 # Lint -freezegun==1.1.0 # Freeze time in tests -furo==2021.11.23 -isort==5.10.1 # Lint imports -keyring==23.4.0 -mypy==0.930 # Type checking -pip_check_reqs==2.3.2 -pydocstyle==6.1.1 # Lint docstrings -pyenchant==3.2.2 # Bindings for a spellchecking sytem -pylint==2.12.2 # Lint -pyroma==3.2 # Packaging best practices checker -pytest-cov==3.0.0 # Measure code coverage -pytest-envfiles==0.1.0 # Use files for environment variables for tests -pytest==6.2.5 # Test runners -requests-mock-flask==2021.12.13 -sphinx-autodoc-typehints==1.12.0 -sphinx_paramlinks==0.5.2 -sphinxcontrib-httpdomain==1.8.0 -sphinxcontrib-spelling==7.3.0 -types-Flask==1.1.6 -types-freezegun==1.1.3 -types-PyYAML==6.0.1 -types-requests==2.26.2 -types-setuptools==57.4.4 -vulture==2.3 -vws-python==2021.3.28.2 diff --git a/requirements/requirements.txt b/requirements/requirements.txt deleted file mode 100644 index b3fcb2850..000000000 --- a/requirements/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -Pillow -VWS-Auth-Tools -flask -requests-mock -requests -tzdata; sys_platform == 'win32' diff --git a/requirements/setup-requirements.txt b/requirements/setup-requirements.txt deleted file mode 100644 index 97ab044e1..000000000 --- a/requirements/setup-requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -setuptools_scm==6.3.2 -setuptools-scm-git-archive==1.1 diff --git a/secrets.tar.gpg b/secrets.tar.gpg index 3ddd59e61..576c69750 100644 Binary files a/secrets.tar.gpg and b/secrets.tar.gpg differ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 36af9a627..000000000 --- a/setup.cfg +++ /dev/null @@ -1,39 +0,0 @@ -[flake8] -exclude=./.eggs, - ./build/, - -[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.9 - 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 05f129308..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 __future__ import annotations - -from pathlib import Path - -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/requirements.txt'), -) - -DEV_REQUIRES = _get_dependencies( - requirements_file=Path('requirements/dev-requirements.txt'), -) - -SETUP_REQUIRES = _get_dependencies( - requirements_file=Path('requirements/setup-requirements.txt'), -) - -setup( - 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 2b4299923..d4ddc4a7e 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -3,13 +3,16 @@ MPixel MiB MissingSchema Ubuntu +VuMark admin +another's api args ascii auth backend backends +beartype binascii bool boolean @@ -17,6 +20,7 @@ bytesio changelog chunked cmyk +config connectionerror customizable dataclass @@ -27,6 +31,7 @@ dev dict docstring docstrings +eof exc filename foo @@ -34,11 +39,13 @@ formdata github greyscale gzip +hardcoded hexdigits hmac html http https +httpx iff io issuecomment @@ -52,6 +59,8 @@ linters linting login macOS +matcher +matchers mb metadata mib @@ -60,11 +69,15 @@ multipart mypy nat noqa +outerboundary +overridable pdict plugins png pragma processable +pyrefly +pyright pytest readme readthedocs @@ -72,6 +85,14 @@ recognitions refactoring regex reimplementation +reportAssignmentType +reportAttributeAccessIssue +reportGeneralTypeIssues +reportMissingTypeStubs +reportPrivateImportUsage +reportUnknownArgumentType +reportUnknownMemberType +reportUnknownVariableType repr reqheader reqjson @@ -79,14 +100,19 @@ 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..86151570d 100644 --- a/src/mock_vws/__init__.py +++ b/src/mock_vws/__init__.py @@ -1,9 +1,9 @@ -""" -Tools for using a fake implementation of Vuforia. -""" +"""Tools for using a fake implementation of Vuforia.""" +from mock_vws._mock_common import MissingSchemeError from mock_vws._requests_mock_server.decorators import MockVWS __all__ = [ - 'MockVWS', + "MissingSchemeError", + "MockVWS", ] 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..c6077c62a 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -1,49 +1,80 @@ -""" -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" + # While we sometimes hit this, we don't want to keep a database that is + # constantly in this state. + REQUEST_QUOTA_REACHED = "RequestQuotaReached" + TARGET_STATUS_NOT_SUCCESS = "TargetStatusNotSuccess" + PROJECT_INACTIVE = "ProjectInactive" + 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 171ac14e2..dae0253a7 100644 --- a/src/mock_vws/_database_matchers.py +++ b/src/mock_vws/_database_matchers.py @@ -1,26 +1,27 @@ -""" -Helpers for getting databases which match keys given in requests. -""" +"""Helpers for getting databases which match keys given in requests.""" -from __future__ import annotations - -from typing import Dict, Iterable +from collections.abc import Iterable, Mapping +from beartype import beartype from vws_auth_tools import authorization_header -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase + +AnyDatabase = CloudDatabase | VuMarkDatabase +@beartype def get_database_matching_client_keys( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes | None, request_method: str, request_path: str, - databases: Iterable[VuforiaDatabase], -) -> VuforiaDatabase | None: - """ - 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. @@ -31,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( 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, @@ -50,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], +@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], -) -> VuforiaDatabase | None: - """ - 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. @@ -73,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( 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, @@ -92,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..d81b96c5c --- /dev/null +++ b/src/mock_vws/_flask_server/Dockerfile @@ -0,0 +1,32 @@ +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. +RUN useradd -ms /bin/bash myuser +USER myuser +COPY --chown=myuser:myuser . /app + +# 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 +RUN uv sync --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 index e69de29bb..81533727f 100644 --- a/src/mock_vws/_flask_server/__init__.py +++ 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/dockerfiles/target_manager/Dockerfile b/src/mock_vws/_flask_server/dockerfiles/target_manager/Dockerfile deleted file mode 100644 index e23650f76..000000000 --- a/src/mock_vws/_flask_server/dockerfiles/target_manager/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.9.1-slim-buster -RUN apt update --yes -# git is needed for setuptools-scm. -RUN apt install --yes git -COPY . /app -WORKDIR /app -RUN pip install . -EXPOSE 5000 -ENTRYPOINT ["python"] -CMD ["src/mock_vws/_flask_server/target_manager.py"] diff --git a/src/mock_vws/_flask_server/dockerfiles/vwq/Dockerfile b/src/mock_vws/_flask_server/dockerfiles/vwq/Dockerfile deleted file mode 100644 index 1db6dfa70..000000000 --- a/src/mock_vws/_flask_server/dockerfiles/vwq/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.9.1-slim-buster -RUN apt update --yes -# git is needed for setuptools-scm. -RUN apt install --yes git -COPY . /app -WORKDIR /app -RUN pip install . -EXPOSE 5000 -ENTRYPOINT ["python"] -CMD ["src/mock_vws/_flask_server/vwq.py"] diff --git a/src/mock_vws/_flask_server/dockerfiles/vws/Dockerfile b/src/mock_vws/_flask_server/dockerfiles/vws/Dockerfile deleted file mode 100644 index eaa05991b..000000000 --- a/src/mock_vws/_flask_server/dockerfiles/vws/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.9.1-slim-buster -RUN apt update --yes -# git is needed for setuptools-scm. -RUN apt install --yes git -COPY . /app -WORKDIR /app -RUN pip install . -EXPOSE 5000 -ENTRYPOINT ["python"] -CMD ["src/mock_vws/_flask_server/vws.py"] diff --git a/src/mock_vws/_flask_server/healthcheck.py b/src/mock_vws/_flask_server/healthcheck.py new file mode 100644 index 000000000..62cfbb971 --- /dev/null +++ b/src/mock_vws/_flask_server/healthcheck.py @@ -0,0 +1,31 @@ +"""Health check for the Flask server.""" + +import http.client +import socket +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() + except TimeoutError, http.client.HTTPException, socket.gaierror: + return False + finally: + conn.close() + + return response.status in { + HTTPStatus.NOT_FOUND, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + } + + +if __name__ == "__main__": + 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 index 7ba846460..dcdced8ec 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -1,236 +1,424 @@ -""" -Storage layer for the mock Vuforia Flask application. -""" +"""Storage layer for the mock Vuforia Flask application.""" import base64 -import dataclasses +import copy import datetime -import random -from http import HTTPStatus -from typing import Tuple +import json +from enum import StrEnum, auto +from http import HTTPMethod, HTTPStatus from zoneinfo import ZoneInfo -from flask import Flask, jsonify, request +from beartype import beartype +from flask import Flask, Response, request +from pydantic_settings import BaseSettings -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.database_type import DatabaseType from mock_vws.states import States -from mock_vws.target import Target +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(__name__) +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) -> TargetTrackingRater: + """Get the target rater.""" + match self: + case self.BRISQUE: + return BrisqueTargetTrackingRater() + case self.PERFECT: + return HardcodedTargetTrackingRater(rating=5) + case self.RANDOM: + return RandomTargetTrackingRater() + case _: # pragma: no cover + raise ValueError + + +@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( - '/databases/', - methods=['DELETE'], + rule="/cloud_databases/", + methods=[HTTPMethod.DELETE], ) -def delete_database(database_name: str) -> Tuple[str, int]: - """ - Delete a database. +@beartype +def delete_cloud_database(database_name: str) -> Response: + """Delete a cloud database. - :status 200: The database has been deleted. + :status 200: The cloud database has been deleted. """ try: (matching_database,) = { database - for database in TARGET_MANAGER.databases + for database in TARGET_MANAGER.cloud_databases if database_name == database.database_name } except ValueError: - return '', HTTPStatus.NOT_FOUND + return Response(response="", status=HTTPStatus.NOT_FOUND) - TARGET_MANAGER.remove_database(database=matching_database) - return '', HTTPStatus.OK + TARGET_MANAGER.remove_cloud_database(cloud_database=matching_database) + return Response(response="", status=HTTPStatus.OK) -@TARGET_MANAGER_FLASK_APP.route('/databases', methods=['GET']) -def get_databases() -> Tuple[str, int]: - """ - Return a list of all databases. +@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. """ - databases = [database.to_dict() for database in TARGET_MANAGER.databases] - return jsonify(databases), HTTPStatus.OK + 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('/databases', methods=['POST']) -def create_database() -> Tuple[str, int]: - """ - Create a new database. + +@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 - database. + cloud database. + :reqjson string client_secret_key: (Optional) The client secret key for the - database. - :reqjson string database_name: (Optional) The name of the database. + cloud database. + + :reqjson string database_name: (Optional) The name of the cloud database. + :reqjson string server_access_key: (Optional) The server access key for the - database. + 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" or "PROJECT_INACTIVE". This defaults to "WORKING". + + :resjson string client_access_key: The client access key for the cloud database. - :reqjson string state_name: (Optional) The state of the database. This can - be "WORKING" or "PROJECT_INACTIVE". This defaults to "WORKING". - - :resjson string client_access_key: The client access key for the database. - :resjson string client_secret_key: The client secret key for the database. - :resjson string database_name: The database name. - :resjson string server_access_key: The server access key for the database. - :resjson string server_secret_key: The server secret key for the database. - :resjson string state_name: The database state. This will be "WORKING" or - "PROJECT_INACTIVE". - :reqjsonarr targets: The targets in the database. - :status 201: The database has been successfully created. + :resjson string client_secret_key: The client secret key for the cloud + database. + + :resjson string database_name: The cloud database name. + + :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. This will be + "WORKING" or "PROJECT_INACTIVE". + + :reqjsonarr targets: The targets in the cloud database. + + :status 201: The cloud database has been successfully created. """ - random_database = VuforiaDatabase() - server_access_key = request.json.get( - 'server_access_key', + 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', + server_secret_key = request_json.get( + "server_secret_key", random_database.server_secret_key, ) - client_access_key = request.json.get( - 'client_access_key', + client_access_key = request_json.get( + "client_access_key", random_database.client_access_key, ) - client_secret_key = request.json.get( - 'client_secret_key', + client_secret_key = request_json.get( + "client_secret_key", random_database.client_secret_key, ) - database_name = request.json.get( - 'database_name', + database_name = request_json.get( + "database_name", random_database.database_name, ) - state_name = request.json.get( - 'state_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, + ) state = States[state_name] + database_type = DatabaseType[database_type_name] - database = VuforiaDatabase( + 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_name=database_name, state=state, + database_type=database_type, ) try: - TARGET_MANAGER.add_database(database=database) + TARGET_MANAGER.add_cloud_database(cloud_database=database) except ValueError as exc: - return str(exc), HTTPStatus.CONFLICT - - return jsonify(database.to_dict()), HTTPStatus.CREATED + 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( - '/databases//targets', - methods=['POST'], + rule="/vumark_databases", + methods=[HTTPMethod.POST], ) -def create_target(database_name: str) -> Tuple[str, int]: - """ - Create a new target in a given database. +@beartype +def create_vumark_database() -> Response: + """Create a new VuMark database. + + :status 201: The database has been successfully created. """ - [database] = [ + 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="/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.databases + for database in TARGET_MANAGER.cloud_databases if database.database_name == database_name - ] - image_base64 = request.json['image_base64'] - image_bytes = base64.b64decode(image_base64) - target = Target( - name=request.json['name'], - width=request.json['width'], + ) + 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'], + 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 jsonify(target.to_dict()), HTTPStatus.CREATED + return Response( + response=json.dumps(obj=target.to_dict()), + status=HTTPStatus.CREATED, + ) @TARGET_MANAGER_FLASK_APP.route( - '/databases//targets/', - methods=['DELETE'], + rule="/vumark_databases//vumark_targets", + methods=[HTTPMethod.POST], ) -def delete_target(database_name: str, target_id: str) -> Tuple[str, int]: - """ - Delete a target. - """ - [database] = [ +@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.databases + 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) - new_target = dataclasses.replace(target, delete_date=now) + # 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 jsonify(new_target.to_dict()), HTTPStatus.OK + return Response( + response=json.dumps(obj=new_target.to_dict()), + status=HTTPStatus.OK, + ) @TARGET_MANAGER_FLASK_APP.route( - '/databases//targets/', - methods=['PUT'], + rule="/cloud_databases//targets/", + methods=[HTTPMethod.PUT], ) -def update_target(database_name: str, target_id: str) -> Tuple[str, int]: - """ - Update a target. - """ - [database] = [ +@beartype +def update_target(database_name: str, target_id: str) -> Response: + """Update a target.""" + (database,) = ( database - for database in TARGET_MANAGER.databases + for database in TARGET_MANAGER.cloud_databases if database.database_name == database_name - ] - target = database.get_target(target_id=target_id) - - width = request.json.get('width', target.width) - name = request.json.get('name', target.name) - active_flag = request.json.get('active_flag', target.active_flag) - application_metadata = request.json.get( - 'application_metadata', - target.application_metadata, ) + target = database.get_target(target_id=target_id) - image_value = target.image_value - if 'image' in request.json: - image_value = base64.b64decode(request.json['image']) - - # 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)) - {target.tracking_rating}) - processed_tracking_rating = random.choice(available_values) + 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('GMT') + gmt = ZoneInfo(key="GMT") last_modified_date = datetime.datetime.now(tz=gmt) - new_target = dataclasses.replace( - target, + 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, - processed_tracking_rating=processed_tracking_rating, last_modified_date=last_modified_date, ) database.targets.remove(target) database.targets.add(new_target) - return jsonify(new_target.to_dict()), HTTPStatus.OK + return Response( + response=json.dumps(obj=new_target.to_dict()), + status=HTTPStatus.OK, + ) -if __name__ == '__main__': # pragma: no cover - TARGET_MANAGER_FLASK_APP.run(debug=True, host='0.0.0.0') +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 index 373e34286..4fb1e75c1 100644 --- a/src/mock_vws/_flask_server/vwq.py +++ b/src/mock_vws/_flask_server/vwq.py @@ -1,53 +1,90 @@ -""" -A fake implementation of the Vuforia Web Query API using Flask. +"""A fake implementation of the Vuforia Web Query API using Flask. 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 """ import email.utils -import os -from http import HTTPStatus -from typing import Set +import time +from enum import StrEnum, auto +from http import HTTPMethod, HTTPStatus import requests +from beartype import beartype from flask import Flask, Response, request +from pydantic_settings import BaseSettings from mock_vws._query_tools import ( - ActiveMatchingTargetsDeleteProcessing, get_query_match_response_text, ) from mock_vws._query_validators import run_query_validators from mock_vws._query_validators.exceptions import ( - MatchProcessing, - ValidatorException, + ValidatorError, +) +from mock_vws.database import CloudDatabase +from mock_vws.image_matchers import ( + ExactMatcher, + ImageMatcher, + StructuralSimilarityMatcher, ) -from mock_vws.database import VuforiaDatabase -CLOUDRECO_FLASK_APP = Flask(import_name=__name__) -CLOUDRECO_FLASK_APP.config['PROPAGATE_EXCEPTIONS'] = True +CLOUDRECO_FLASK_APP = Flask(import_name=__name__, static_folder=None) +CLOUDRECO_FLASK_APP.config["PROPAGATE_EXCEPTIONS"] = True -def get_all_databases() -> Set[VuforiaDatabase]: - """ - Get all database objects from the target manager back-end. - """ - target_manager_base_url = os.environ['TARGET_MANAGER_BASE_URL'] - response = requests.get(url=f'{target_manager_base_url}/databases') +@beartype +class _ImageMatcherChoice(StrEnum): + """Image matcher choices.""" + + EXACT = auto() + STRUCTURAL_SIMILARITY = auto() + + def to_image_matcher(self) -> ImageMatcher: + """Get the image matcher.""" + match self: + case self.EXACT: + return ExactMatcher() + case self.STRUCTURAL_SIMILARITY: + return StructuralSimilarityMatcher() + case _: # pragma: no cover + raise ValueError + + +@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 { - VuforiaDatabase.from_dict(database_dict=database_dict) + 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``, so that requests have the given ``Content-Length`` headers - and the given data in ``request.headers`` and ``request.data``. + """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 set this to ``False`` when running an application as standalone. + 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. @@ -55,52 +92,49 @@ def set_terminate_wsgi_input() -> None: same as the real Vuforia. This is documented as a difference in the documentation for this package. """ - terminate_wsgi_input = CLOUDRECO_FLASK_APP.config.get( - 'TERMINATE_WSGI_INPUT', - False, - ) - request.environ['wsgi.input_terminated'] = terminate_wsgi_input - - -class ResponseNoContentTypeAdded(Response): - """ - A custom response type. - - Without this, a content type is added to all responses. - Some of our responses need to not have a "Content-Type" header. - """ + 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 - default_mimetype = None + if set_terminate_wsgi_input_true: + request.environ["wsgi.input_terminated"] = True -CLOUDRECO_FLASK_APP.response_class = ResponseNoContentTypeAdded +@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(ValidatorException) -def handle_exceptions(exc: ValidatorException) -> Response: - """ - Return the error response associated with the given exception. - """ - return ResponseNoContentTypeAdded( +@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('/v1/query', methods=['POST']) + +@CLOUDRECO_FLASK_APP.route(rule="/v1/query", methods=[HTTPMethod.POST]) +@beartype def query() -> Response: - """ - Perform an image recognition query. - """ - query_processes_deletion_seconds = float( - os.environ.get('DELETION_PROCESSING_SECONDS', '3.0'), - ) - query_recognizes_deletion_seconds = float( - os.environ.get('DELETION_RECOGNITION_SECONDS', '0.2'), - ) + """Perform an image recognition query.""" + settings = VWQSettings.model_validate(obj={}) + query_match_checker = settings.query_image_matcher.to_image_matcher() - databases = get_all_databases() + databases = get_all_cloud_databases() request_body = request.stream.read() run_query_validators( request_headers=dict(request.headers), @@ -109,28 +143,22 @@ def query() -> Response: request_path=request.path, databases=databases, ) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) - try: - 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_processes_deletion_seconds=query_processes_deletion_seconds, - query_recognizes_deletion_seconds=( - query_recognizes_deletion_seconds - ), - ) - except ActiveMatchingTargetsDeleteProcessing as exc: - raise MatchProcessing from exc + 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', + "Content-Type": "application/json", + "Date": date, + "Connection": "keep-alive", + "Server": "nginx", } return Response( status=HTTPStatus.OK, @@ -139,5 +167,6 @@ def query() -> Response: ) -if __name__ == '__main__': # pragma: no cover - CLOUDRECO_FLASK_APP.run(debug=True, host='0.0.0.0') +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 index e005122fe..57942cb20 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -1,72 +1,130 @@ -""" -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 email.utils import json -import os +import logging +import time import uuid -from http import HTTPStatus -from typing import Set +from enum import StrEnum, auto +from http import HTTPMethod, HTTPStatus import requests +from beartype import beartype from flask import Flask, Response, request - -from mock_vws._constants import ResultCodes, TargetStatuses +from pydantic_settings import BaseSettings + +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 json_dump from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( - Fail, - TargetStatusNotSuccess, - TargetStatusProcessing, - ValidatorException, + FailError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + ValidatorError, +) +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.image_matchers import ( + ExactMatcher, + ImageMatcher, + StructuralSimilarityMatcher, +) +from mock_vws.target import ImageTarget +from mock_vws.target_raters import ( + HardcodedTargetTrackingRater, ) -from mock_vws.database import VuforiaDatabase -from mock_vws.target import Target -VWS_FLASK_APP = Flask(import_name=__name__) -VWS_FLASK_APP.config['PROPAGATE_EXCEPTIONS'] = True +VWS_FLASK_APP = Flask(import_name=__name__, static_folder=None) +VWS_FLASK_APP.config["PROPAGATE_EXCEPTIONS"] = True -def get_all_databases() -> Set[VuforiaDatabase]: - """ - Get all database objects from the task manager back-end. - """ - target_manager_base_url = os.environ['TARGET_MANAGER_BASE_URL'] - response = requests.get(url=f'{target_manager_base_url}/databases') - return { - VuforiaDatabase.from_dict(database_dict=database_dict) - for database_dict in response.json() - } +_LOGGER = logging.getLogger(name=__name__) -class ResponseNoContentTypeAdded(Response): - """ - A custom response type. +@beartype +class _ImageMatcherChoice(StrEnum): + """Image matcher choices.""" - Without this, a content type is added to all responses. - Some of our responses need to not have a "Content-Type" header. - """ + EXACT = auto() + STRUCTURAL_SIMILARITY = auto() + + def to_image_matcher(self) -> ImageMatcher: + """Get the image matcher.""" + match self: + case self.EXACT: + return ExactMatcher() + case self.STRUCTURAL_SIMILARITY: + return StructuralSimilarityMatcher() + case _: # pragma: no cover + raise ValueError - default_mimetype = None +@beartype +class VWSSettings(BaseSettings): + """Settings for the VWS Flask app.""" -VWS_FLASK_APP.response_class = ResponseNoContentTypeAdded + target_manager_base_url: str + processing_time_seconds: float = 2.0 + vws_host: str = "" + 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() + } @VWS_FLASK_APP.before_request +@beartype def set_terminate_wsgi_input() -> None: - """ - We set ``wsgi.input_terminated`` to ``True`` when going through - ``requests``, so that requests have the given ``Content-Length`` headers - and the given data in ``request.headers`` and ``request.data``. + """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 set this to ``False`` when running an application as standalone. + 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. @@ -74,52 +132,70 @@ def set_terminate_wsgi_input() -> None: same as the real Vuforia. This is documented as a difference in the documentation for this package. """ - terminate_wsgi_input = VWS_FLASK_APP.config.get( - 'TERMINATE_WSGI_INPUT', - False, - ) - request.environ['wsgi.input_terminated'] = terminate_wsgi_input + 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. """ - Run validators on the request. - """ - databases = get_all_databases() + if request.endpoint == "generate_vumark_instance": + return run_services_validators( request_headers=dict(request.headers), request_body=request.data, request_method=request.method, request_path=request.path, - databases=databases, + databases=get_all_cloud_databases(), ) -@VWS_FLASK_APP.errorhandler(ValidatorException) -def handle_exceptions(exc: ValidatorException) -> Response: - """ - Return the error response associated with the given exception. - """ - return ResponseNoContentTypeAdded( +@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.route('/targets', methods=['POST']) +@VWS_FLASK_APP.route(rule="/targets", methods=[HTTPMethod.POST]) +@beartype def add_target() -> Response: - """ - Add a target. + """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 """ - processing_time_seconds = float( - os.environ.get('PROCESSING_TIME_SECONDS', '0.5'), - ) - databases = get_all_databases() + 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, @@ -128,61 +204,71 @@ def add_target() -> Response: databases=databases, ) - assert isinstance(database, VuforiaDatabase) - # 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(request.data) - name = request_json['name'] - active_flag = request_json.get('active_flag') + 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 - new_target = Target( + # This rater is not used. + target_tracking_rater = HardcodedTargetTrackingRater(rating=1) + + new_target = ImageTarget( name=name, - width=request_json['width'], - image_value=base64.b64decode(request_json['image']), + width=request_json["width"], + image_value=base64.b64decode(s=request_json["image"]), active_flag=active_flag, - processing_time_seconds=processing_time_seconds, - application_metadata=request_json.get('application_metadata'), + processing_time_seconds=settings.processing_time_seconds, + application_metadata=request_json.get("application_metadata"), + target_tracking_rater=target_tracking_rater, ) - target_manager_base_url = os.environ['TARGET_MANAGER_BASE_URL'] - databases_url = f'{target_manager_base_url}/databases' + databases_url = f"{settings.target_manager_base_url}/cloud_databases" + timeout_seconds = 30 requests.post( - url=f'{databases_url}/{database.database_name}/targets', + url=f"{databases_url}/{database.database_name}/targets", json=new_target.to_dict(), + timeout=timeout_seconds, ) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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, + "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), + response=json_dump(body=body), headers=headers, ) -@VWS_FLASK_APP.route('/targets/', methods=['GET']) +@VWS_FLASK_APP.route( + rule="/targets/", methods=[HTTPMethod.GET] +) +@beartype def get_target(target_id: str) -> Response: - """ - Get details of a target. + """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 """ - databases = get_all_databases() + databases = get_all_cloud_databases() database = get_database_matching_server_keys( request_headers=dict(request.headers), request_body=request.data, @@ -191,49 +277,59 @@ def get_target(target_id: str) -> Response: databases=databases, ) - assert isinstance(database, VuforiaDatabase) - [target] = [ + (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': 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(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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, + "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), + response=json_dump(body=body), headers=headers, ) -@VWS_FLASK_APP.route('/targets/', methods=['DELETE']) +@VWS_FLASK_APP.route( + rule="/targets/", + methods=[HTTPMethod.DELETE], +) +@beartype def delete_target(target_id: str) -> Response: - """ - Delete a target. + """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 """ - databases = get_all_databases() + 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, @@ -242,47 +338,123 @@ def delete_target(target_id: str) -> Response: databases=databases, ) - assert isinstance(database, VuforiaDatabase) - [target] = [ + (target,) = ( target for target in database.targets if target.target_id == target_id - ] + ) if target.status == TargetStatuses.PROCESSING.value: - raise TargetStatusProcessing + raise TargetStatusProcessingError - target_manager_base_url = os.environ['TARGET_MANAGER_BASE_URL'] - databases_url = f'{target_manager_base_url}/databases' + databases_url = f"{settings.target_manager_base_url}/cloud_databases" requests.delete( - url=f'{databases_url}/{database.database_name}/targets/{target_id}', + url=f"{databases_url}/{database.database_name}/targets/{target_id}", + timeout=30, ) body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, } - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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), + response=json_dump(body=body), headers=headers, ) -@VWS_FLASK_APP.route('/summary', methods=['GET']) -def database_summary() -> Response: +@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 """ - Get a database summary report. + 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, + ) + + 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 + + request_json = json.loads(s=request.data) + instance_id = request_json.get("instance_id", "") + if not instance_id: + raise InvalidInstanceIdError + + 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://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 """ - databases = get_all_databases() + databases = get_all_cloud_databases() database = get_database_matching_server_keys( request_headers=dict(request.headers), request_body=request.data, @@ -291,48 +463,54 @@ def database_summary() -> Response: databases=databases, ) - assert isinstance(database, VuforiaDatabase) 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, + "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, + "request_usage": 0, } - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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), + response=json_dump(body=body), headers=headers, ) -@VWS_FLASK_APP.route('/summary/', methods=['GET']) +@VWS_FLASK_APP.route( + rule="/summary/", + methods=[HTTPMethod.GET], +) +@beartype def target_summary(target_id: str) -> Response: - """ - Get a summary report for a target. + """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 """ - databases = get_all_databases() + databases = get_all_cloud_databases() database = get_database_matching_server_keys( request_headers=dict(request.headers), request_body=request.data, @@ -341,46 +519,57 @@ def target_summary(target_id: str) -> Response: databases=databases, ) - assert isinstance(database, VuforiaDatabase) - [target] = [ + (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('%Y-%m-%d'), - 'active_flag': target.active_flag, - 'tracking_rating': target.tracking_rating, - 'total_recos': target.total_recos, - 'current_month_recos': target.current_month_recos, - 'previous_month_recos': target.previous_month_recos, + "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(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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), + response=json_dump(body=body), headers=headers, ) -@VWS_FLASK_APP.route('/duplicates/', methods=['GET']) +@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. + """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 """ - databases = get_all_databases() + 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, @@ -388,51 +577,58 @@ def get_duplicates(target_id: str) -> Response: request_path=request.path, databases=databases, ) + image_match_checker = settings.duplicates_image_matcher.to_image_matcher() - assert isinstance(database, VuforiaDatabase) - [target] = [ + (target,) = ( target for target in database.targets if target.target_id == target_id - ] - other_targets = set(database.targets) - {target} + ) + other_targets = database.targets - {target} - similar_targets: list[str] = [ + similar_targets = [ other.target_id for other in other_targets - if other.image_value == target.image_value - and TargetStatuses.FAILED.value not in (target.status, other.status) + 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, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "similar_targets": similar_targets, } - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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), + response=json_dump(body=body), headers=headers, ) -@VWS_FLASK_APP.route('/targets', methods=['GET']) +@VWS_FLASK_APP.route(rule="/targets", methods=[HTTPMethod.GET]) +@beartype def target_list() -> Response: - """ - Get a list of all targets. + """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 """ - databases = get_all_databases() + databases = get_all_cloud_databases() database = get_database_matching_server_keys( request_headers=dict(request.headers), request_body=request.data, @@ -440,40 +636,46 @@ def target_list() -> Response: request_path=request.path, databases=databases, ) - assert isinstance(database, VuforiaDatabase) results = [target.target_id for target in database.not_deleted_targets] body = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.SUCCESS.value, - 'results': results, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "results": results, } - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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), + response=json_dump(body=body), headers=headers, ) -@VWS_FLASK_APP.route('/targets/', methods=['PUT']) +@VWS_FLASK_APP.route( + rule="/targets/", methods=[HTTPMethod.PUT] +) +@beartype def update_target(target_id: str) -> Response: - """ - Update a target. + """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 """ + 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(request.data) - databases = get_all_databases() + 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, @@ -482,62 +684,77 @@ def update_target(target_id: str) -> Response: databases=databases, ) - assert isinstance(database, VuforiaDatabase) - [target] = [ + (target,) = ( target for target in database.targets if target.target_id == target_id - ] + ) if target.status != TargetStatuses.SUCCESS.value: - raise TargetStatusNotSuccess + raise TargetStatusNotSuccessError - update_values = {} - if 'width' in request_json: - update_values['width'] = request_json['width'] + 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" in request_json: + active_flag = request_json["active_flag"] if active_flag is None: - raise Fail(status_code=HTTPStatus.BAD_REQUEST) - update_values['active_flag'] = active_flag - - if 'application_metadata' in request_json: - application_metadata = request_json['application_metadata'] + _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: - raise Fail(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 + _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 - target_manager_base_url = os.environ['TARGET_MANAGER_BASE_URL'] put_url = ( - f'{target_manager_base_url}/databases/{database.database_name}/' - f'targets/{target_id}' + f"{settings.target_manager_base_url}/cloud_databases/" + f"{database.database_name}/targets/{target_id}" ) - requests.put(url=put_url, json=update_values) + requests.put(url=put_url, json=update_values, timeout=30) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, + "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, + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, } return Response( status=HTTPStatus.OK, - response=json_dump(body), + response=json_dump(body=body), headers=headers, ) -if __name__ == '__main__': # pragma: no cover - VWS_FLASK_APP.run(debug=True, host='0.0.0.0') +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/_mock_common.py b/src/mock_vws/_mock_common.py index 0ac08740f..2c975d86a 100644 --- a/src/mock_vws/_mock_common.py +++ b/src/mock_vws/_mock_common.py @@ -1,16 +1,58 @@ -""" -Common utilities for creating mock routes. -""" +"""Common utilities for creating mock routes.""" import json +from collections.abc import Iterable, Mapping from dataclasses import dataclass -from typing import Any, Dict, FrozenSet +from typing import Any +from beartype import beartype -@dataclass(frozen=True) -class Route: + +@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. + + 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. """ - A representation of a VWS route. + + method: str + path: str + headers: Mapping[str, str] + body: bytes + + +@beartype +@dataclass(frozen=True, kw_only=True) +class Route: + """A representation of a VWS route. Args: route_name: The name of the method. @@ -21,12 +63,13 @@ class Route: route_name: str path_pattern: str - http_methods: FrozenSet[str] + http_methods: Iterable[str] -def json_dump(body: Dict[str, Any]) -> str: +@beartype +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=(',', ':')) + return json.dumps(obj=body, separators=(",", ":")) diff --git a/src/mock_vws/_query_tools.py b/src/mock_vws/_query_tools.py index c706b34dd..3c030844e 100644 --- a/src/mock_vws/_query_tools.py +++ b/src/mock_vws/_query_tools.py @@ -1,38 +1,32 @@ -""" -Tools for making Vuforia queries. -""" - -from __future__ import annotations +"""Tools for making Vuforia queries.""" import base64 -import cgi -import datetime import io import uuid -from typing import Any, Dict, Set -from zoneinfo import ZoneInfo +from collections.abc import Iterable, Mapping +from email.message import EmailMessage +from typing import Any + +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 ActiveMatchingTargetsDeleteProcessing(Exception): - """ - There is at least one active target which matches and was recently deleted. - """ +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: int | float, - query_recognizes_deletion_seconds: int | float, + databases: Iterable[CloudDatabase], + query_match_checker: ImageMatcher, ) -> str: """ Args: @@ -41,47 +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: - 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']) + max_num_results = fields.get(key="max_num_results", default="1") + include_target_data = fields.get( + key="include_target_data", + default="top", + ).lower() - [include_target_data] = parsed.get('include_target_data', ['top']) - include_target_data = include_target_data.lower() - - [image_value] = parsed['image'] - assert isinstance(image_value, bytes) - gmt = ZoneInfo('GMT') - now = datetime.datetime.now(tz=gmt) - - processing_timedelta = datetime.timedelta( - seconds=query_processes_deletion_seconds, - ) - - 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, @@ -91,83 +69,68 @@ def get_query_match_response_text( databases=databases, ) - assert isinstance(database, VuforiaDatabase) - matching_targets = [ target for target in database.targets - if target.image_value == image_value + 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 - ] - - 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 + all_quality_matches = not_deleted_matches + minimum_rating = 0 + matches = [ + match + for match in all_quality_matches + if match.tracking_rating > minimum_rating ] - 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..fe3e966f6 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..13553efa4 100644 --- a/src/mock_vws/_query_validators/auth_validators.py +++ b/src/mock_vws/_query_validators/auth_validators.py @@ -1,111 +1,121 @@ -""" -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 +125,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 958f642ca..586978ccc 100644 --- a/src/mock_vws/_query_validators/content_type_validators.py +++ b/src/mock_vws/_query_validators/content_type_validators.py @@ -1,47 +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 ( - ImageNotGiven, - NoBoundaryFound, - NoContentType, - 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. - ImageNotGiven: The boundary is not in the request body. - NoContentType: The content type header is either empty or not given. + 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 content_type_header == '': - raise NoContentType + 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 - if main_value not in ('multipart/form-data', '*/*'): - raise UnsupportedMediaType + 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 ImageNotGiven + 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 1781cf4ab..116aff3eb 100644 --- a/src/mock_vws/_query_validators/date_validators.py +++ b/src/mock_vws/_query_validators/date_validators.py @@ -1,99 +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 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( - {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 @@ -103,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 4b1bffff8..bb830651b 100644 --- a/src/mock_vws/_query_validators/exceptions.py +++ b/src/mock_vws/_query_validators/exceptions.py @@ -1,85 +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 typing import Dict + +from beartype import beartype from mock_vws._constants import ResultCodes from mock_vws._mock_common import json_dump -class ValidatorException(Exception): +@beartype +class ValidatorError(Exception): """ - A base class for exceptions thrown from mock Vuforia cloud recognition + A base class for exceptions thrown from mock Vuforia cloud + recognition client endpoints. """ status_code: HTTPStatus response_text: str - headers: Dict[str, str] + headers: Mapping[str, str] -class DateHeaderNotGiven(ValidatorException): - """ - Exception raised when a date header is not given. - """ +@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.' - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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(ValidatorException): - """ - 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.' - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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': 'VWS', - 'Content-Length': str(len(self.response_text)), + "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(ValidatorException): - """ - 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'. """ @@ -88,29 +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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class BadImage(ValidatorException): - """ - 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'. """ @@ -119,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__() @@ -133,22 +148,26 @@ def __init__(self) -> None: '{"transaction_id": ' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" ) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class AuthenticationFailure(ValidatorException): - """ - 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'. """ @@ -157,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__() @@ -171,22 +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, ) - date = email.utils.formatdate(None, localtime=False, usegmt=True) self.headers = { - 'Content-Type': 'application/json', - 'Connection': 'keep-alive', - 'Server': 'nginx', - 'Date': date, - 'WWW-Authenticate': 'VWS', - 'Content-Length': str(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "WWW-Authenticate": "VWS", + "Content-Length": str(object=len(self.response_text)), } -class AuthenticationFailureGoodFormatting(ValidatorException): - """ - 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. """ @@ -195,141 +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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "WWW-Authenticate": "VWS", + "Content-Length": str(object=len(self.response_text)), } -class ImageNotGiven(ValidatorException): - """ - 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(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class AuthHeaderMissing(ValidatorException): - """ - 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(None, localtime=False, usegmt=True) + 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': 'VWS', - 'Content-Length': str(len(self.response_text)), + "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(ValidatorException): - """ - 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(None, localtime=False, usegmt=True) + 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': 'VWS', - 'Content-Length': str(len(self.response_text)), + "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(ValidatorException): - """ - 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(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class InactiveProject(ValidatorException): - """ - 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'. """ @@ -338,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__() @@ -351,23 +398,28 @@ def __init__(self) -> None: '{"transaction_id": ' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" ) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class InvalidMaxNumResults(ValidatorException): - """ - 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: @@ -375,30 +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(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class MaxNumResultsOutOfRange(ValidatorException): - """ - 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. """ @@ -407,30 +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(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class InvalidIncludeTargetData(ValidatorException): - """ - 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. """ @@ -439,169 +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(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "Content-Type": "application/json", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class UnsupportedMediaType(ValidatorException): - """ - 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(None, localtime=False, usegmt=True) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) self.headers = { - 'Connection': 'keep-alive', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(self.response_text)), + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class InvalidAcceptHeader(ValidatorException): - """ - 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(None, localtime=False, usegmt=True) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) self.headers = { - 'Connection': 'keep-alive', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(self.response_text)), + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class NoBoundaryFound(ValidatorException): - """ - Exception raised when an invalid media type is given. - """ +@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.io.IOException: RESTEASY007550: ' - 'Unable to get boundary for multipart' - ) - - date = email.utils.formatdate(None, localtime=False, usegmt=True) - self.headers = { - 'Content-Type': 'text/html;charset=utf-8', - 'Connection': 'keep-alive', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(self.response_text)), - } - - -class QueryOutOfBounds(ValidatorException): - """ - Exception raised when VWS returns an HTML page which says that there is a - particular out of bounds error. - """ + self.response_text = "Unable to get boundary for multipart" - 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.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 - - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) self.headers = { - 'Content-Type': 'text/html;charset=iso-8859-1', - 'Connection': 'keep-alive', - 'Server': 'nginx', - 'Date': date, - 'Cache-Control': 'must-revalidate,no-cache,no-store', - 'Content-Length': str(len(self.response_text)), + "Content-Type": "text/plain;charset=utf-8", + "Connection": "keep-alive", + "Server": "nginx", + "Date": date, + "Content-Length": str(object=len(self.response_text)), } -class ContentLengthHeaderTooLarge(ValidatorException): +@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.response_text = "" self.headers = { - 'Connection': 'keep-alive', - 'Content-Length': str(len(self.response_text)), + "Connection": "keep-alive", + "Content-Length": str(object=len(self.response_text)), } -class ContentLengthHeaderNotInt(ValidatorException): +@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: @@ -609,35 +659,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.BAD_REQUEST - self.response_text = '' + self.response_text = "" self.headers = { - 'Connection': 'Close', - 'Content-Length': str(len(self.response_text)), + "Connection": "Close", + "Content-Length": str(object=len(self.response_text)), } -class RequestEntityTooLarge(ValidatorException): - """ - Exception raised when the given image file size 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.REQUEST_ENTITY_TOO_LARGE self.response_text = textwrap.dedent( - """\ + text="""\ \r 413 Request Entity Too Large\r \r @@ -647,59 +701,25 @@ def __init__(self) -> None: \r """, ) - date = email.utils.formatdate(None, localtime=False, usegmt=True) - self.headers = { - 'Connection': 'Close', - 'Date': date, - 'Server': 'nginx', - 'Content-Type': 'text/html', - 'Content-Length': str(len(self.response_text)), - } - - -class MatchProcessing(ValidatorException): - """ - Exception raised a target is matched which is processing or recently - deleted. - """ - - 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.INTERNAL_SERVER_ERROR - date = email.utils.formatdate(None, localtime=False, usegmt=True) - # 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 - self.response_text = Path(match_processing_resp_file).read_text( - encoding='utf-8', + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, ) 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(len(self.response_text)), + "Connection": "Close", + "Date": date, + "Server": "nginx", + "Content-Type": "text/html", + "Content-Length": str(object=len(self.response_text)), } -class NoContentType(ValidatorException): +@beartype +class NoContentTypeError(ValidatorError): """ - Exception raised when a content type is either not given or is empty. + Exception raised when a content type is either not given or is + empty. """ def __init__(self) -> None: @@ -707,27 +727,32 @@ 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 - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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

+ +

HTTP ERROR 400 Bad Request

- + -
URI:/v1/query
URI:http://cloudreco.vuforia.com/v1/query
STATUS:400
MESSAGE:Bad Request
SERVLET:Resteasy
-
Powered by Jetty:// 9.4.43.v20210629
+
Powered by Jetty:// 12.0.20
@@ -735,10 +760,10 @@ def __init__(self) -> None: ) 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(len(self.response_text)), + "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 a3b4c94fd..8c0494c7e 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -1,188 +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 +from beartype import beartype from PIL import Image +from werkzeug.datastructures import FileStorage, MultiDict +from werkzeug.formparser import MultiPartParser from mock_vws._query_validators.exceptions import ( - BadImage, - ImageNotGiven, - RequestEntityTooLarge, + 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: - RequestEntityTooLarge: 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 RequestEntityTooLarge + # 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 Image.open(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 Image.open(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 Image.open(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 f5fcfa169..000000000 --- a/src/mock_vws/_query_validators/resources/query_out_of_bounds_response.html +++ /dev/null @@ -1,19 +0,0 @@ - - - -Error 500 java.lang.ArrayIndexOutOfBoundsException - -

HTTP ERROR 500 java.lang.ArrayIndexOutOfBoundsException

- - - - - - -
URI:/v1/query
STATUS:500
MESSAGE:java.lang.ArrayIndexOutOfBoundsException
SERVLET:Resteasy
CAUSED BY:java.lang.ArrayIndexOutOfBoundsException
-

Caused by:

java.lang.ArrayIndexOutOfBoundsException
-
-
Powered by Jetty:// 9.4.43.v20210629
- - - 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 index 14a2846fc..ae699a3a1 100644 --- a/src/mock_vws/_requests_mock_server/decorators.py +++ b/src/mock_vws/_requests_mock_server/decorators.py @@ -1,158 +1,268 @@ -""" -Decorators for using the mock. -""" - -from __future__ import annotations +"""Decorators for using the mock.""" import re +import time +from collections.abc import Callable, Mapping from contextlib import ContextDecorator -from typing import Literal, Tuple -from urllib.parse import urljoin, urlparse +from typing import TYPE_CHECKING, Any, Literal, Self +from urllib.parse import urlparse import requests -from requests_mock.mocker import Mocker +from beartype import BeartypeConf, beartype +from requests import PreparedRequest +from responses import RequestsMock -from mock_vws.database import VuforiaDatabase +from mock_vws._mock_common import MissingSchemeError, RequestData +from mock_vws._respx_mock_server.decorators import start_respx_router +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.image_matchers import ( + ImageMatcher, + StructuralSimilarityMatcher, +) from mock_vws.target_manager import TargetManager +from mock_vws.target_raters import ( + BrisqueTargetTrackingRater, + TargetTrackingRater, +) from .mock_web_query_api import MockVuforiaWebQueryAPI from .mock_web_services_api import MockVuforiaWebServicesAPI +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. + """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', + *, + base_vws_url: str = "https://vws.vuforia.com", + base_vwq_url: str = "https://cloudreco.vuforia.com", + duplicate_match_checker: ImageMatcher = _STRUCTURAL_SIMILARITY_MATCHER, + query_match_checker: ImageMatcher = _STRUCTURAL_SIMILARITY_MATCHER, + processing_time_seconds: float = 2.0, + target_tracking_rater: TargetTrackingRater = _BRISQUE_TRACKING_RATER, real_http: bool = False, - processing_time_seconds: int | float = 0.5, - query_recognizes_deletion_seconds: int | float = 0.2, - query_processes_deletion_seconds: int | float = 3, + response_delay_seconds: float = 0.0, + sleep_fn: Callable[[float], None] = time.sleep, ) -> None: - """ - Route requests to Vuforia's Web Service APIs to fakes of those APIs. + """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. + 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. + 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: - requests.exceptions.MissingSchema: There is no schema in a given - URL. + MissingSchemeError: There is no scheme in a given URL. """ super().__init__() self._real_http = real_http - self._mock: Mocker + 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 - 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) + parse_result = urlparse(url=url) + if not parse_result.scheme: + raise MissingSchemeError(url=url) self._mock_vws_api = MockVuforiaWebServicesAPI( target_manager=self._target_manager, - processing_time_seconds=processing_time_seconds, + processing_time_seconds=float(processing_time_seconds), + duplicate_match_checker=duplicate_match_checker, + target_tracking_rater=target_tracking_rater, ) self._mock_vwq_api = MockVuforiaWebQueryAPI( target_manager=self._target_manager, - query_processes_deletion_seconds=( - query_processes_deletion_seconds - ), - query_recognizes_deletion_seconds=( - query_recognizes_deletion_seconds - ), + query_match_checker=query_match_checker, ) - def add_database(self, database: VuforiaDatabase) -> None: - """ - Add a cloud database. + def add_cloud_database(self, cloud_database: CloudDatabase) -> None: + """Add a cloud database. Args: - database: The database to add. + cloud_database: The cloud database to add. Raises: - ValueError: One of the given database keys matches a key for an - existing database. + ValueError: One of the given cloud database keys matches a key for + an existing cloud database. """ - self._target_manager.add_database(database=database) + self._target_manager.add_cloud_database( + cloud_database=cloud_database, + ) + + def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None: + """Add a VuMark database. - def __enter__(self) -> 'MockVWS': + Args: + vumark_database: The VuMark database to add. + + Raises: + ValueError: One of the given database keys matches a key for + an existing database. """ - Start an instance of a Vuforia mock. + 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(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) - 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 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: - mock.register_uri( + original_callback = getattr(api, route.route_name) + mock.add_callback( method=http_method, - url=re.compile(url_pattern), - text=getattr(self._mock_vws_api, route.route_name), + 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, ) - 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), - ) + 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: Tuple[None, None, None]) -> Literal[False]: - """ - Stop the Vuforia mock. + 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. - for _ in (exc,): - pass + del exc self._mock.stop() + self._router.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 6bceab0b1..b7d7aad57 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,40 +1,51 @@ -""" -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 __future__ import annotations - import email.utils -from typing import Callable, Set +from collections.abc import Callable, Iterable, Mapping +from http import HTTPMethod, HTTPStatus +from typing import ParamSpec, Protocol, runtime_checkable -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 +from mock_vws._mock_common import RequestData, Route from mock_vws._query_tools import ( - ActiveMatchingTargetsDeleteProcessing, get_query_match_response_text, ) from mock_vws._query_validators import run_query_validators from mock_vws._query_validators.exceptions import ( - MatchProcessing, - ValidatorException, + ValidatorError, ) +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] +_P = ParamSpec("_P") + + +@runtime_checkable +class _RouteMethod(Protocol[_P]): + """Callable used for routing which also exposes ``__name__``.""" + + __name__: str + + 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 @@ -45,110 +56,84 @@ 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), ) + _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, target_manager: TargetManager, - query_recognizes_deletion_seconds: int | float, - query_processes_deletion_seconds: int | float, + query_match_checker: ImageMatcher, ) -> None: """ Args: target_manager: The target manager which holds all 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. Attributes: routes: The `Route`s to be used in the mock. """ - self.routes: Set[Route] = ROUTES + self.routes = _ROUTES self._target_manager = target_manager - self._query_processes_deletion_seconds = ( - query_processes_deletion_seconds - ) - self._query_recognizes_deletion_seconds = ( - query_recognizes_deletion_seconds - ) + self._query_match_checker = query_match_checker - @route(path_pattern='/v1/query', http_methods={POST}) - def query( - self, - request: _RequestObjectProxy, - context: _Context, - ) -> str: - """ - Perform an image recognition query. - """ + @route(path_pattern="/v1/query", http_methods={HTTPMethod.POST}) + def query(self, request: RequestData) -> _ResponseType: + """Perform an image recognition query.""" try: run_query_validators( request_path=request.path, request_headers=request.headers, request_body=request.body, request_method=request.method, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_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, + ) - try: - 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.databases, - query_processes_deletion_seconds=( - self._query_processes_deletion_seconds - ), - query_recognizes_deletion_seconds=( - self._query_recognizes_deletion_seconds - ), - ) - except ActiveMatchingTargetsDeleteProcessing: - match_processing_exception = MatchProcessing() - context.headers = match_processing_exception.headers - context.status_code = match_processing_exception.status_code - return match_processing_exception.response_text - - date = email.utils.formatdate(None, localtime=False, usegmt=True) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(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 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 0304c7139..50fc7aa95 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,123 +1,152 @@ -""" -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 """ -from __future__ import annotations - import base64 -import dataclasses +import copy import datetime import email.utils -import random +import json import uuid -from http import HTTPStatus -from typing import Callable, Dict, Set +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 requests_mock import DELETE, GET, POST, PUT -from requests_mock.request import _RequestObjectProxy -from requests_mock.response import _Context +from beartype import BeartypeConf, beartype -from mock_vws._constants import ResultCodes, TargetStatuses +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 Route, json_dump +from mock_vws._mock_common import RequestData, Route, json_dump from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( - Fail, - TargetStatusNotSuccess, - TargetStatusProcessing, - ValidatorException, + FailError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + ValidatorError, ) -from mock_vws.database import VuforiaDatabase -from mock_vws.target import Target +from mock_vws.database import VuMarkDatabase +from mock_vws.image_matchers import ImageMatcher +from mock_vws.target import ImageTarget from mock_vws.target_manager import TargetManager +from mock_vws.target_raters import TargetTrackingRater + +if TYPE_CHECKING: + from mock_vws.database import CloudDatabase + +_TARGET_ID_PATTERN = "[A-Za-z0-9]+" -_TARGET_ID_PATTERN = '[A-Za-z0-9]+' +_ROUTES: set[Route] = set() -ROUTES = set() +_ResponseType = tuple[int, Mapping[str, str], str | bytes] +_P = ParamSpec("_P") +@runtime_checkable +class _RouteMethod(Protocol[_P]): + """Callable used for routing which also exposes ``__name__``.""" + + __name__: str + + 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), ) + _ROUTES.add(new_route) return method return decorator +@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, + *, target_manager: TargetManager, - processing_time_seconds: int | float, + processing_time_seconds: float, + duplicate_match_checker: ImageMatcher, + target_tracking_rater: TargetTrackingRater, ) -> None: """ Args: target_manager: Target Manager which stores databases. 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. + 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. Attributes: routes: The `Route`s to be used in the mock. """ self._target_manager = target_manager - self.routes: Set[Route] = ROUTES + self.routes = _ROUTES self._processing_time_seconds = processing_time_seconds + self._duplicate_match_checker = duplicate_match_checker + self._target_tracking_rater = target_tracking_rater @route( - path_pattern='/targets', - http_methods={POST}, + path_pattern="/targets", + http_methods={HTTPMethod.POST}, ) - def add_target( - self, - request: _RequestObjectProxy, - context: _Context, - ) -> str: - """ - Add a target. + 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 """ try: run_services_validators( @@ -125,73 +154,74 @@ def add_target( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text + 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._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - - given_active_flag = request.json().get('active_flag') + 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] - application_metadata = request.json().get('application_metadata') + application_metadata = request_json.get("application_metadata") - new_target = Target( - name=request.json()['name'], - width=request.json()['width'], - image_value=base64.b64decode(request.json()['image']), + 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=application_metadata, + target_tracking_rater=self._target_tracking_rater, ) database.targets.add(new_target) - date = email.utils.formatdate(None, localtime=False, usegmt=True) - 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) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(body_json)), + 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 body_json + 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 """ try: run_services_validators( @@ -199,63 +229,137 @@ def delete_target( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text - body: Dict[str, str] = {} database = get_database_matching_server_keys( request_headers=request.headers, request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - target_id = request.path.split('/')[-1] + target_id = request.path.split(sep="/")[-1] target = database.get_target(target_id=target_id) if target.status == TargetStatuses.PROCESSING.value: - target_processing_exception = TargetStatusProcessing() - context.headers = target_processing_exception.headers - context.status_code = target_processing_exception.status_code - return target_processing_exception.response_text + target_processing_exception = TargetStatusProcessingError() + return ( + target_processing_exception.status_code, + target_processing_exception.headers, + target_processing_exception.response_text, + ) now = datetime.datetime.now(tz=target.upload_date.tzinfo) - new_target = dataclasses.replace(target, delete_date=now) + # 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(None, localtime=False, usegmt=True) + 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) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(body_json)), + 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 body_json + return HTTPStatus.OK, headers, body_json - @route(path_pattern='/summary', http_methods={GET}) - def database_summary( - self, - request: _RequestObjectProxy, - context: _Context, - ) -> 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.""" + 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, + ) + + 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 + + request_json = json.loads(s=request.body) + instance_id = request_json.get("instance_id", "") + if not instance_id: + raise InvalidInstanceIdError + 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 """ try: run_services_validators( @@ -263,62 +367,60 @@ def database_summary( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text - - body: Dict[str, str | int] = {} + 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._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) 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, - '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) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(body_json)), + 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 body_json + return HTTPStatus.OK, headers, body_json - @route(path_pattern='/targets', http_methods={GET}) - def target_list( - self, - request: _RequestObjectProxy, - context: _Context, - ) -> 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( @@ -326,51 +428,56 @@ def target_list( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text + 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._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) - results = [target.target_id for target in database.not_deleted_targets] - body: Dict[str, str | list[str]] = { - 'transaction_id': uuid.uuid4().hex, - 'result_code': ResultCodes.SUCCESS.value, - 'results': results, + response_results = [ + target.target_id for target in database.not_deleted_targets + ] + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.SUCCESS.value, + "results": response_results, } - body_json = json_dump(body) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(body_json)), + 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 body_json + 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, - ) -> 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 """ try: run_services_validators( @@ -378,64 +485,68 @@ def get_target( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text + 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._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - target_id = request.path.split('/')[-1] + 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(None, localtime=False, usegmt=True) + 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) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(body_json)), + 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 body_json + 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, - ) -> 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 """ try: run_services_validators( @@ -443,67 +554,70 @@ def get_duplicates( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text + 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._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - target_id = request.path.split('/')[-1] + target_id = request.path.split(sep="/")[-1] target = database.get_target(target_id=target_id) - other_targets = set(database.targets) - {target} + other_targets = database.targets - {target} - similar_targets: list[str] = [ + similar_targets = [ other.target_id for other in other_targets - if other.image_value == target.image_value + 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(None, localtime=False, usegmt=True) + 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) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(body_json)), + 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 body_json + 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 """ try: run_services_validators( @@ -511,79 +625,80 @@ def update_target( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text + 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._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - - target_id = request.path.split('/')[-1] + target_id = request.path.split(sep="/")[-1] target = database.get_target(target_id=target_id) - body: Dict[str, str] = {} - date = email.utils.formatdate(None, localtime=False, usegmt=True) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) if target.status != TargetStatuses.SUCCESS.value: - exception = TargetStatusNotSuccess() - context.headers = exception.headers - context.status_code = exception.status_code - return exception.response_text - - width = request.json().get('width', target.width) - name = request.json().get('name', target.name) - active_flag = request.json().get('active_flag', target.active_flag) - application_metadata = request.json().get( - 'application_metadata', + 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) + + 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(request.json()['image']) - - if 'active_flag' in request.json() and active_flag is None: - fail_exception = Fail(status_code=HTTPStatus.BAD_REQUEST) - context.headers = fail_exception.headers - context.status_code = fail_exception.status_code - return fail_exception.response_text + if "image" in request_json: + image_value = base64.b64decode(s=request_json["image"]) if ( - 'application_metadata' in request.json() + "application_metadata" in request_json and application_metadata is None ): - fail_exception = Fail(status_code=HTTPStatus.BAD_REQUEST) - context.headers = fail_exception.headers - context.status_code = fail_exception.status_code - return 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)) - {target.tracking_rating}) - processed_tracking_rating = random.choice(available_values) - - gmt = ZoneInfo('GMT') - last_modified_date = datetime.datetime.now(tz=gmt) + fail_exception = FailError(status_code=HTTPStatus.BAD_REQUEST) + return ( + fail_exception.status_code, + fail_exception.headers, + fail_exception.response_text, + ) - new_target = dataclasses.replace( - target, + # 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, - processed_tracking_rating=processed_tracking_rating, last_modified_date=last_modified_date, ) @@ -591,30 +706,32 @@ def update_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, } - body_json = json_dump(body) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(body_json)), + 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 body_json + 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, - ) -> 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 """ try: run_services_validators( @@ -622,46 +739,54 @@ def target_summary( request_body=request.body, request_method=request.method, request_path=request.path, - databases=self._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - except ValidatorException as exc: - context.headers = exc.headers - context.status_code = exc.status_code - return exc.response_text + 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._target_manager.databases, + databases=self._target_manager.cloud_databases, ) - assert isinstance(database, VuforiaDatabase) - target_id = request.path.split('/')[-1] + target_id = request.path.split(sep="/")[-1] target = database.get_target(target_id=target_id) - assert isinstance(database, VuforiaDatabase) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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': target.total_recos, - 'current_month_recos': target.current_month_recos, - 'previous_month_recos': target.previous_month_recos, + "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) - context.headers = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Content-Length': str(len(body_json)), - 'Server': 'nginx', - 'Date': date, + 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 body_json + 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..090695d0a --- /dev/null +++ b/src/mock_vws/_respx_mock_server/decorators.py @@ -0,0 +1,183 @@ +"""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(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 387c4bd25..026d46800 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 ( @@ -29,10 +29,11 @@ validate_image_data_type, validate_image_encoding, validate_image_format, + validate_image_integrity, validate_image_is_image, validate_image_size, ) -from .json_validators import validate_json +from .json_validators import validate_body_given, validate_json from .key_validators import validate_keys from .metadata_validators import ( validate_metadata_encoding, @@ -51,15 +52,16 @@ 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], ) -> None: - """ - Run all validators. + """Run all validators. Args: request_path: The path of the request. @@ -95,10 +97,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, @@ -115,6 +125,7 @@ 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_integrity(request_body=request_body) validate_name_type(request_body=request_body) validate_name_length(request_body=request_body) @@ -144,11 +155,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/date_validators.py b/src/mock_vws/_services_validators/date_validators.py index 980ffd458..f5f773d97 100644 --- a/src/mock_vws/_services_validators/date_validators.py +++ b/src/mock_vws/_services_validators/date_validators.py @@ -1,73 +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 mock_vws._services_validators.exceptions import Fail, RequestTimeTooSkewed +from beartype import beartype +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 fcbcf5d50..da058422d 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -1,30 +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 typing import Dict + +from beartype import beartype from mock_vws._constants import ResultCodes from mock_vws._mock_common import json_dump -class ValidatorException(Exception): +@beartype +class ValidatorError(Exception): """ - A base class for exceptions thrown from mock Vuforia services endpoints. + A base class for exceptions thrown from mock Vuforia services + endpoints. """ status_code: HTTPStatus response_text: str - headers: Dict[str, str] + headers: Mapping[str, str] -class UnknownTarget(ValidatorException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class UnknownTargetError(ValidatorError): + """Exception raised when Vuforia returns a response with a result code 'UnknownTarget'. """ @@ -33,29 +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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 ProjectInactive(ValidatorException): - """ - 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'. """ @@ -64,29 +75,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.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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 AuthenticationFailure(ValidatorException): - """ - 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'. """ @@ -95,60 +115,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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 Fail(ValidatorException): - """ - 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: HTTPStatus) -> 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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 MetadataTooLarge(ValidatorException): - """ - 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: @@ -156,30 +195,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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 TargetNameExist(ValidatorException): - """ - 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: @@ -187,32 +235,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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 OopsErrorOccurredResponse(ValidatorException): - """ - 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: @@ -220,29 +275,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 - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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 = { - 'Content-Type': 'text/html; charset=UTF-8', - 'Connection': 'keep-alive', - 'Server': 'nginx', - 'Date': date, - 'Content-Length': str(len(self.response_text)), + "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(ValidatorException): - """ - 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'. """ @@ -251,29 +315,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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 ImageTooLarge(ValidatorException): - """ - 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'. """ @@ -282,29 +355,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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 RequestTimeTooSkewed(ValidatorException): - """ - 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'. """ @@ -313,51 +395,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) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 ContentLengthHeaderTooLarge(ValidatorException): +@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 = { - 'Connection': 'keep-alive', - 'Content-Length': str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), + "Date": date, + "server": "envoy", + "Content-Type": "text/plain", + "Connection": "close", } -class ContentLengthHeaderNotInt(ValidatorException): +@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: @@ -365,44 +470,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.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(len(self.response_text)), + "Connection": "close", + "Content-Length": str(object=len(self.response_text)), + "Date": date, + "Server": "awselb/2.0", + "Content-Type": "text/html", } -class UnnecessaryRequestBody(ValidatorException): - """ - Exception raised when a request body is given but not necessary. - """ +@beartype +class UnnecessaryRequestBodyError(ValidatorError): + """Exception raised when a request body is given but not necessary.""" 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(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), } -class TargetStatusNotSuccess(ValidatorException): +@beartype +class TargetStatusNotSuccessError(ValidatorError): """ Exception raised when trying to update a target that does not have a success status. @@ -413,29 +541,115 @@ 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, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_STATUS_NOT_SUCCESS.value, } - self.response_text = json_dump(body) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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 TargetStatusProcessing(ValidatorException): - """ - Exception raised when trying to delete a target which is processing. +@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 + 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: @@ -443,21 +657,68 @@ 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.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, + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.TARGET_STATUS_PROCESSING.value, } - self.response_text = json_dump(body) - date = email.utils.formatdate(None, localtime=False, usegmt=True) + 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(len(self.response_text)), + "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..e5413b7f8 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -1,187 +1,220 @@ -""" -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 beartype import beartype from PIL import Image from mock_vws._base64_decoding import decode_base64 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 Image.open(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. -def validate_image_color_space(request_body: bytes) -> None: + Args: + request_body: The body of the request. + + 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 Image.open(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 Image.open(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_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: - BadImage: Image data is given and it is not an image file. + 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 Image.open(fp=image_file) as _: + pass except OSError as exc: - raise BadImage from exc + 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/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 a2b00f22e..708d3b09d 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -1,22 +1,23 @@ -""" -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 .exceptions import FailError +_LOGGER = logging.getLogger(name=__name__) -@dataclass + +@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 +29,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,75 +49,82 @@ 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}, + 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}, + path_pattern="/summary", + http_methods={HTTPMethod.GET}, mandatory_keys=set(), optional_keys=set(), ) target_list = _Route( - path_pattern='/targets', - http_methods={GET}, + 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}, + 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}, + 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}, + 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}, + 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", }, ) + generate_instance = _Route( + path_pattern=f"/targets/{target_id_pattern}/instances", + http_methods={HTTPMethod.POST}, + mandatory_keys={"instance_id"}, + optional_keys=set(), + ) + target_summary = _Route( - path_pattern=f'/summary/{target_id_pattern}', - http_methods={GET}, + path_pattern=f"/summary/{target_id_pattern}", + http_methods={HTTPMethod.GET}, mandatory_keys=set(), optional_keys=set(), ) @@ -128,30 +137,35 @@ def validate_keys( 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 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 5a04c7f92..abf1532c0 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -1,27 +1,33 @@ -""" -Validators for target names. -""" +"""Validators for target names.""" import json -from http import HTTPStatus -from typing import Dict, Set +import logging +from collections.abc import Iterable, Mapping +from http import HTTPMethod, HTTPStatus -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 ( - Fail, - OopsErrorOccurredResponse, - TargetNameExist, + FailError, + TargetNameExistError, ) -from mock_vws.database import VuforiaDatabase + +_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. @@ -29,91 +35,96 @@ 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) -def validate_name_length(request_body: bytes) -> None: - """ - Validate the length of the name argument given to a VWS endpoint. +@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: - Fail: A name is given and it is not a between 1 and 64 characters in - length. + 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(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 name and len(name) < 65: + max_length = 64 + if name and len(name) <= max_length: return - raise Fail(status_code=HTTPStatus.BAD_REQUEST) + _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: Set[VuforiaDatabase], + *, + databases: Iterable[AnyDatabase], request_body: bytes, - request_headers: Dict[str, str], + request_headers: Mapping[str, str], request_method: str, request_path: str, ) -> None: - """ - Validate that the name does not exist for any existing target. + """Validate that the name does not exist for any existing target. Args: databases: All Vuforia databases. @@ -123,20 +134,22 @@ def validate_name_does_not_exist_new_target( request_path: The path to the endpoint. Raises: - TargetNameExist: The target name already exists. + TargetNameExistError: The target name already exists. """ 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('/') - if len(split_path) != 2: + 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'] + name = json.loads(s=request_text)["name"] database = get_database_matching_server_keys( request_headers=request_headers, request_body=request_body, @@ -144,7 +157,6 @@ def validate_name_does_not_exist_new_target( request_path=request_path, databases=databases, ) - assert isinstance(database, VuforiaDatabase) matching_name_targets = [ target @@ -155,18 +167,21 @@ def validate_name_does_not_exist_new_target( if not matching_name_targets: return - raise TargetNameExist + _LOGGER.warning(msg="Target name already exists.") + raise TargetNameExistError +@beartype def validate_name_does_not_exist_existing_target( - request_headers: Dict[str, str], + *, + request_headers: Mapping[str, str], request_body: bytes, request_method: str, request_path: str, - databases: Set[VuforiaDatabase], + databases: Iterable[AnyDatabase], ) -> None: - """ - Validate that the name does not exist for any existing target apart from + """Validate that the name does not exist for any existing target apart + from the one being updated. Args: @@ -177,24 +192,24 @@ def validate_name_does_not_exist_existing_target( request_path: The path to the endpoint. Raises: - TargetNameExist: The target name is not the same as the name of the - target being updated but it is the same as another target. + 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('/') - if len(split_path) == 2: + split_path = request_path.split(sep="/") + split_path_no_target_id_length = 2 + if len(split_path) == split_path_no_target_id_length: return target_id = split_path[-1] - name = json.loads(request_text)['name'] + name = json.loads(s=request_text)["name"] database = get_database_matching_server_keys( request_headers=request_headers, request_body=request_body, @@ -202,7 +217,6 @@ def validate_name_does_not_exist_existing_target( request_path=request_path, databases=databases, ) - assert isinstance(database, VuforiaDatabase) matching_name_targets = [ target @@ -213,8 +227,9 @@ def validate_name_does_not_exist_existing_target( if not matching_name_targets: return - [matching_name_target] = matching_name_targets + (matching_name_target,) = matching_name_targets if matching_name_target.target_id == target_id: return - raise TargetNameExist + _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..d0a07b0fb 100644 --- a/src/mock_vws/_services_validators/project_state_validators.py +++ b/src/mock_vws/_services_validators/project_state_validators.py @@ -1,24 +1,32 @@ -""" -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 ProjectInactiveError +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 +36,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 +47,18 @@ def validate_project_state( databases=databases, ) - assert isinstance(database, VuforiaDatabase) 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/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index f23a43811..58f1da0d7 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -1,23 +1,31 @@ -""" -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 +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._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 +35,22 @@ 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('/') + 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 +59,11 @@ def validate_target_id_exists( databases=databases, ) - assert isinstance(database, VuforiaDatabase) - - try: - [_] = [ - target - for target in database.not_deleted_targets - if target.target_id == target_id - ] - 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/database.py b/src/mock_vws/database.py index 9b16eb901..0d1d46fb1 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -1,22 +1,26 @@ -""" -Utilities for managing mock Vuforia databases. -""" - -from __future__ import annotations +"""Utilities for managing mock Vuforia databases.""" import uuid +from collections.abc import Iterable from dataclasses import dataclass, field -from typing import List, Set, TypedDict +from typing import Self, TypedDict + +from beartype import beartype from mock_vws._constants import TargetStatuses +from mock_vws.database_type import DatabaseType from mock_vws.states import States -from mock_vws.target import Target, TargetDict +from mock_vws.target import ( + ImageTarget, + ImageTargetDict, + VuMarkTarget, + VuMarkTargetDict, +) -class DatabaseDict(TypedDict): - """ - A dictionary type which represents a database. - """ +@beartype +class CloudDatabaseDict(TypedDict): + """A dictionary type which represents a cloud database.""" database_name: str server_access_key: str @@ -24,20 +28,31 @@ class DatabaseDict(TypedDict): client_access_key: str client_secret_key: str state_name: str - targets: List[TargetDict] + database_type_name: str + targets: Iterable[ImageTargetDict] + + +@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_name: The name of a VWS target manager database name. Defaults @@ -64,70 +79,70 @@ class VuforiaDatabase: # ``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[Target] = field(default_factory=set, hash=False) + targets: set[ImageTarget] = field( + default_factory=set[ImageTarget], + hash=False, + ) state: States = States.WORKING - - request_quota = 100000 - reco_threshold = 1000 - current_month_recos = 0 - previous_month_recos = 0 - total_recos = 0 - target_quota = 1000 - - def to_dict(self) -> DatabaseDict: - """ - Dump a target to a dictionary which can be loaded as JSON. - """ - targets = [target.to_dict() for target in self.targets] + 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 + + 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 + ] return { - '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, - 'targets': targets, + "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, } - def get_target(self, target_id: str) -> Target: - """ - Return a target from the database with the given ID. - """ - [target] = [ + 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: DatabaseDict) -> VuforiaDatabase: - """ - Load a database from a dictionary. - """ + 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"] + } + return cls( - 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']], - targets={ - Target.from_dict(target_dict=target_dict) - for target_dict in database_dict['targets'] - }, + 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, ) @property - def not_deleted_targets(self) -> Set[Target]: - """ - All targets which have not been deleted. - """ + 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[Target]: - """ - All active targets. - """ + def active_targets(self) -> set[ImageTarget]: + """All active targets.""" return { target for target in self.not_deleted_targets @@ -136,10 +151,8 @@ def active_targets(self) -> Set[Target]: } @property - def inactive_targets(self) -> Set[Target]: - """ - All inactive targets. - """ + def inactive_targets(self) -> set[ImageTarget]: + """All inactive targets.""" return { target for target in self.not_deleted_targets @@ -148,10 +161,8 @@ def inactive_targets(self) -> Set[Target]: } @property - def failed_targets(self) -> Set[Target]: - """ - All failed targets. - """ + def failed_targets(self) -> set[ImageTarget]: + """All failed targets.""" return { target for target in self.not_deleted_targets @@ -159,12 +170,78 @@ def failed_targets(self) -> Set[Target]: } @property - def processing_targets(self) -> Set[Target]: - """ - All processing targets. - """ + 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/image_matchers.py b/src/mock_vws/image_matchers.py new file mode 100644 index 000000000..363bf75bb --- /dev/null +++ b/src/mock_vws/image_matchers.py @@ -0,0 +1,134 @@ +"""Matchers for query and duplicate requests.""" + +import io +from typing import Protocol, runtime_checkable + +import numpy as np +import torch +from beartype import beartype +from PIL import Image +from torchmetrics.image import ( + StructuralSimilarityIndexMeasure, +) + + +@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 ( + Image.open(fp=first_image_file) as first_image, + Image.open(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_resized = first_image.resize(size=target_size) + second_image_resized = second_image.resize(size=target_size) + + first_image_np = np.array(object=first_image_resized, dtype=np.float32) + first_image_tensor = ( + torch.tensor( # pyright: ignore[reportPrivateImportUsage] + data=first_image_np, + ).float() + / 255 + ) + first_image_tensor = first_image_tensor.view( + first_image_resized.size[1], + first_image_resized.size[0], + len(first_image_resized.getbands()), + ) + + second_image_np = np.array( + object=second_image_resized, + dtype=np.float32, + ) + second_image_tensor = ( + torch.tensor( # pyright: ignore[reportPrivateImportUsage] + data=second_image_np, + ).float() + / 255 + ) + second_image_tensor = second_image_tensor.view( + second_image_resized.size[1], + second_image_resized.size[0], + len(second_image_resized.getbands()), + ) + + first_image_tensor_batch_dimension = first_image_tensor.permute( + 2, + 0, + 1, + ).unsqueeze(dim=0) + second_image_tensor_batch_dimension = second_image_tensor.permute( + 2, + 0, + 1, + ).unsqueeze(dim=0) + + ssim = StructuralSimilarityIndexMeasure(data_range=1.0) + ssim_value = ssim( + first_image_tensor_batch_dimension, + second_image_tensor_batch_dimension, + ) + ssim_score = ssim_value.item() + + # Normalize SSIM score from -1 to 1 scale to 0 to 10 scale. + # This maps -1 to 0 and 1 to 10. + normalized_score = (ssim_score + 1) * 5 + minimum_acceptable_ssim_score = 7 + return bool(normalized_score > minimum_acceptable_ssim_score) 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 71a6a5cae..000000000 --- a/src/mock_vws/resources/match_processing_response.html +++ /dev/null @@ -1,105 +0,0 @@ - - - -Error 500 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] - -

HTTP ERROR 500 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]

- - - - - - - -
URI:/v1/query
STATUS:500
MESSAGE: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]
SERVLET:Resteasy
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]
CAUSED BY:com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input - at [Source: (byte[])""; line: 1, column: 0]
-

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:106)
-	at org.jboss.resteasy.core.ExceptionHandler.handleException(ExceptionHandler.java:372)
-	at org.jboss.resteasy.core.SynchronousDispatcher.writeException(SynchronousDispatcher.java:216)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:515)
-	at org.jboss.resteasy.core.SynchronousDispatcher.lambda$invoke$4(SynchronousDispatcher.java:259)
-	at org.jboss.resteasy.core.SynchronousDispatcher.lambda$preprocess$0(SynchronousDispatcher.java:160)
-	at org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext.filter(PreMatchContainerRequestContext.java:364)
-	at org.jboss.resteasy.core.SynchronousDispatcher.preprocess(SynchronousDispatcher.java:163)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:245)
-	at org.jboss.resteasy.plugins.server.servlet.ServletContainerDispatcher.service(ServletContainerDispatcher.java:249)
-	at org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:60)
-	at org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:55)
-	at javax.servlet.http.HttpServlet.service(HttpServlet.java:790)
-	at org.eclipse.jetty.servlet.ServletHolder$NotAsyncServlet.service(ServletHolder.java:1411)
-	at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:763)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1651)
-	at com.kooaba.queryservice.services.BrokenMultipartBoundaryWorkaround.doFilter(BrokenMultipartBoundaryWorkaround.java:90)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1638)
-	at com.kooaba.queryservice.auth.KWSAuthFilter.doFilter(KWSAuthFilter.java:171)
-	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1630)
-	at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:567)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
-	at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:602)
-	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:127)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:235)
-	at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:1610)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:233)
-	at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1377)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:188)
-	at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:507)
-	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1580)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:186)
-	at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1292)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
-	at org.eclipse.jetty.server.handler.ContextHandlerCollection.handle(ContextHandlerCollection.java:191)
-	at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:146)
-	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:127)
-	at org.eclipse.jetty.server.Server.handle(Server.java:501)
-	at org.eclipse.jetty.server.HttpChannel.lambda$handle$1(HttpChannel.java:383)
-	at org.eclipse.jetty.server.HttpChannel.dispatch(HttpChannel.java:556)
-	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:375)
-	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:273)
-	at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:311)
-	at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:105)
-	at org.eclipse.jetty.io.ChannelEndPoint$1.run(ChannelEndPoint.java:104)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.runTask(EatWhatYouKill.java:336)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.doProduce(EatWhatYouKill.java:313)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.tryProduce(EatWhatYouKill.java:171)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.run(EatWhatYouKill.java:129)
-	at org.eclipse.jetty.util.thread.ReservedThreadExecutor$ReservedThread.run(ReservedThreadExecutor.java:375)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:806)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.run(QueuedThreadPool.java:938)
-	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:4666)
-	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4511)
-	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3544)
-	at com.kooaba.queryservice.domain.WebResult.setTargetData(WebResult.java:44)
-	at com.kooaba.queryservice.domain.WebQueryResultProcessor.formatResult(WebQueryResultProcessor.java:82)
-	at com.kooaba.queryservice.services.QueryResourceVuforia.queryCommon(QueryResourceVuforia.java:231)
-	at com.kooaba.queryservice.services.QueryResourceVuforia.limitedConcurrencyQuery(QueryResourceVuforia.java:78)
-	at com.kooaba.queryservice.services.QueryResourceCloudRecoWebAPI.query(QueryResourceCloudRecoWebAPI.java:55)
-	at sun.reflect.GeneratedMethodAccessor89.invoke(Unknown Source)
-	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
-	at java.lang.reflect.Method.invoke(Method.java:498)
-	at org.jboss.resteasy.core.MethodInjectorImpl.invoke(MethodInjectorImpl.java:167)
-	at org.jboss.resteasy.core.MethodInjectorImpl.invoke(MethodInjectorImpl.java:130)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.internalInvokeOnTarget(ResourceMethodInvoker.java:638)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTargetAfterFilter(ResourceMethodInvoker.java:504)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.lambda$invokeOnTarget$2(ResourceMethodInvoker.java:454)
-	at org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext.filter(PreMatchContainerRequestContext.java:364)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTarget(ResourceMethodInvoker.java:456)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:417)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:391)
-	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:68)
-	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:488)
-	... 49 more
-
-
Powered by Jetty:// 9.4.43.v20210629
- - - diff --git a/src/mock_vws/resources/oops_error_occurred_response.html b/src/mock_vws/resources/oops_error_occurred_response.html deleted file mode 100644 index e72b8fc60..000000000 --- a/src/mock_vws/resources/oops_error_occurred_response.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - Error - - - -

Oops, an error occurred

- -

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

- - - diff --git a/src/mock_vws/states.py b/src/mock_vws/states.py index 2b3f49ecd..e57a09734 100644 --- a/src/mock_vws/states.py +++ b/src/mock_vws/states.py @@ -1,22 +1,16 @@ -""" -Vuforia database states. -""" +"""Vuforia database states.""" -from enum import Enum, auto +from enum import StrEnum, auto, unique +from beartype import beartype -class States(Enum): - """ - Constants representing various web service states. - """ + +@beartype +@unique +class States(StrEnum): + """Constants representing various web service states.""" WORKING = auto() # A project is inactive if the license key has been deleted. PROJECT_INACTIVE = auto() - - def __repr__(self) -> str: - """ - Return a representation which does not include the generated number. - """ - return f'<{self.__class__.__name__}.{self.name}>' diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index e6fb8ea2a..557c0d2be 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -1,67 +1,67 @@ -""" -A fake implementation of a target for the Vuforia Web Services API. -""" -from __future__ import annotations +"""A fake implementation of a target for the Vuforia Web Services API.""" import base64 import datetime import io -import random import statistics import uuid from dataclasses import dataclass, field -from typing import TypedDict +from typing import Self, TypedDict from zoneinfo import ZoneInfo +from beartype import BeartypeConf, beartype from PIL import Image, ImageStat from mock_vws._constants import TargetStatuses +from mock_vws.target_raters import ( + HardcodedTargetTrackingRater, + TargetTrackingRater, +) -class TargetDict(TypedDict): - """ - A dictionary type which represents a target. - """ +class VuMarkTargetDict(TypedDict): + """A dictionary type which represents a VuMark target.""" + + target_id: str + name: str + processing_time_seconds: float + last_modified_date: str + upload_date: str + + +class ImageTargetDict(TypedDict): + """A dictionary type which represents an image target.""" name: str width: float image_base64: str active_flag: bool - processing_time_seconds: int | float - processed_tracking_rating: int + processing_time_seconds: float application_metadata: str | None target_id: str last_modified_date: str delete_date_optional: str | None upload_date: str + tracking_rating: int +@beartype def _random_hex() -> str: - """ - Return a random hex value. - """ + """Return a random hex value.""" return uuid.uuid4().hex +@beartype def _time_now() -> datetime.datetime: - """ - Return the current time in the GMT time zone. - """ - gmt = ZoneInfo('GMT') + """Return the current time in the GMT time zone.""" + gmt = ZoneInfo(key="GMT") return datetime.datetime.now(tz=gmt) -def _random_tracking_rating() -> int: - """ - Return a random tracking rating. - """ - return random.randint(0, 5) - - -@dataclass(frozen=True, eq=True) -class Target: - """ - A Vuforia Target as managed in +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, eq=True, kw_only=True) +class ImageTarget: + """A Vuforia image target as managed in https://developer.vuforia.com/target-manager. """ @@ -71,53 +71,51 @@ class Target: name: str processing_time_seconds: float width: float + target_tracking_rater: TargetTrackingRater = field(compare=False) current_month_recos: int = 0 delete_date: datetime.datetime | None = None last_modified_date: datetime.datetime = field(default_factory=_time_now) previous_month_recos: int = 0 - processed_tracking_rating: int = field( - default_factory=_random_tracking_rating, - ) - reco_rating: str = '' + reco_rating: str = "" target_id: str = field(default_factory=_random_hex) total_recos: int = 0 upload_date: datetime.datetime = field(default_factory=_time_now) @property def _post_processing_status(self) -> TargetStatuses: - """ - Return the status of the target, or what it will be when processing is - finished. + """Return the status of the target, or what it will be when + processing + is finished. The status depends on the standard deviation of the color bands. - How VWS determines this is unknown, but it relates to how suitable the - target is for detection. + How VWS determines this is unknown, but it relates to how + suitable the target is for detection. """ - image_file = io.BytesIO(self.image_value) - image = Image.open(image_file) - image_stat = ImageStat.Stat(image) + image_file = io.BytesIO(initial_bytes=self.image_value) + with Image.open(fp=image_file) as image: + image_stat = ImageStat.Stat(image_or_list=image) + average_std_dev = statistics.mean(data=image_stat.stddev) - average_std_dev = statistics.mean(image_stat.stddev) + success_threshold = 5 - if average_std_dev > 5: + if average_std_dev > success_threshold: return TargetStatuses.SUCCESS return TargetStatuses.FAILED @property def status(self) -> str: - """ - Return the status of the target. + """Return the status of the target. For now this waits half a second (arbitrary) before changing the status from 'processing' to 'failed' or 'success'. The status depends on the standard deviation of the color bands. - How VWS determines this is unknown, but it relates to how suitable the - target is for detection. + How VWS determines this is unknown, but it relates to how + suitable the target is for detection. """ processing_time = datetime.timedelta( - seconds=self.processing_time_seconds, + seconds=float(self.processing_time_seconds), ) timezone = self.upload_date.tzinfo @@ -125,57 +123,48 @@ def status(self) -> str: time_since_change = now - self.last_modified_date if time_since_change <= processing_time: - return str(TargetStatuses.PROCESSING.value) + return TargetStatuses.PROCESSING.value - return str(self._post_processing_status.value) + return self._post_processing_status.value + + @property + def _post_processing_target_rating(self) -> int: + """The rating of the target after processing.""" + return self.target_tracking_rater(image_content=self.image_value) @property def tracking_rating(self) -> int: - """ - Return the tracking rating of the target recognition image. - - In this implementation that is just a random integer between 0 and 5 - if the target status is 'success'. - The rating is 0 if the target status is 'failed'. - The rating is -1 for a short time while the target is being processed. - The real VWS seems to give -1 for a short time while processing, then - the real rating, even while it is still processing. - """ + """Return the tracking rating of the target recognition image.""" pre_rating_time = datetime.timedelta( # That this is half of the total processing time is unrealistic. # In VWS it is not a constant percentage. - seconds=self.processing_time_seconds - / 2, + seconds=float(self.processing_time_seconds) / 2, ) timezone = self.upload_date.tzinfo now = datetime.datetime.now(tz=timezone) time_since_upload = now - self.upload_date + # The real VWS seems to give -1 for a short time while processing, then + # the real rating, even while it is still processing. if time_since_upload <= pre_rating_time: return -1 - if self._post_processing_status == TargetStatuses.SUCCESS: - return self.processed_tracking_rating - - return 0 + return self._post_processing_target_rating @classmethod - def from_dict(cls, target_dict: TargetDict) -> Target: - """ - Load a target from a dictionary. - """ - timezone = ZoneInfo('GMT') - name = target_dict['name'] - active_flag = target_dict['active_flag'] - width = target_dict['width'] - image_base64 = target_dict['image_base64'] - image_value = base64.b64decode(image_base64) - processed_tracking_rating = target_dict['processed_tracking_rating'] - processing_time_seconds = target_dict['processing_time_seconds'] - application_metadata = target_dict['application_metadata'] - target_id = target_dict['target_id'] - delete_date_optional = target_dict['delete_date_optional'] + def from_dict(cls, target_dict: ImageTargetDict) -> Self: + """Load a target from a dictionary.""" + timezone = ZoneInfo(key="GMT") + name = target_dict["name"] + active_flag = target_dict["active_flag"] + width = target_dict["width"] + image_base64 = target_dict["image_base64"] + image_value = base64.b64decode(s=image_base64) + processing_time_seconds = target_dict["processing_time_seconds"] + application_metadata = target_dict["application_metadata"] + target_id = target_dict["target_id"] + delete_date_optional = target_dict["delete_date_optional"] if delete_date_optional is None: delete_date = None else: @@ -183,13 +172,16 @@ def from_dict(cls, target_dict: TargetDict) -> Target: delete_date = delete_date.replace(tzinfo=timezone) last_modified_date = datetime.datetime.fromisoformat( - target_dict['last_modified_date'], + target_dict["last_modified_date"], ).replace(tzinfo=timezone) upload_date = datetime.datetime.fromisoformat( - target_dict['upload_date'], + target_dict["upload_date"], ).replace(tzinfo=timezone) - target = Target( + target_tracking_rater = HardcodedTargetTrackingRater( + rating=target_dict["tracking_rating"], + ) + return cls( target_id=target_id, name=name, active_flag=active_flag, @@ -200,30 +192,94 @@ def from_dict(cls, target_dict: TargetDict) -> Target: delete_date=delete_date, last_modified_date=last_modified_date, upload_date=upload_date, - processed_tracking_rating=processed_tracking_rating, + target_tracking_rater=target_tracking_rater, ) - return target - def to_dict(self) -> TargetDict: - """ - Dump a target to a dictionary which can be loaded as JSON. - """ + def to_dict(self) -> ImageTargetDict: + """Dump a target to a dictionary which can be loaded as JSON.""" delete_date: str | None = None if self.delete_date: - delete_date = datetime.datetime.isoformat(self.delete_date) + delete_date = self.delete_date.isoformat() + + image_base64 = base64.encodebytes(s=self.image_value).decode() + + return { + "name": self.name, + "width": self.width, + "image_base64": image_base64, + "active_flag": self.active_flag, + "processing_time_seconds": float(self.processing_time_seconds), + "application_metadata": self.application_metadata, + "target_id": self.target_id, + "last_modified_date": self.last_modified_date.isoformat(), + "delete_date_optional": delete_date, + "upload_date": self.upload_date.isoformat(), + "tracking_rating": self.tracking_rating, + } + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, eq=True, kw_only=True) +class VuMarkTarget: + """ + A VuMark target as managed in + https://developer.vuforia.com/target-manager. + + Unlike ImageTarget, VuMark targets do not require an image — they use a + VuMark template. + """ + + name: str + processing_time_seconds: float = 0.0 + target_id: str = field(default_factory=_random_hex) + last_modified_date: datetime.datetime = field(default_factory=_time_now) + upload_date: datetime.datetime = field(default_factory=_time_now) - image_base64 = base64.encodebytes(self.image_value).decode() + @property + def status(self) -> str: + """Return the status of the target. + + VuMark targets always succeed after processing. + """ + processing_time = datetime.timedelta( + seconds=float(self.processing_time_seconds), + ) + timezone = self.upload_date.tzinfo + now = datetime.datetime.now(tz=timezone) + time_since_change = now - self.last_modified_date + + if time_since_change <= processing_time: + return TargetStatuses.PROCESSING.value + + return TargetStatuses.SUCCESS.value + + @classmethod + def from_dict(cls, target_dict: VuMarkTargetDict) -> Self: + """Load a VuMark target from a dictionary.""" + timezone = ZoneInfo(key="GMT") + last_modified_date = datetime.datetime.fromisoformat( + target_dict["last_modified_date"], + ).replace(tzinfo=timezone) + upload_date = datetime.datetime.fromisoformat( + target_dict["upload_date"], + ).replace(tzinfo=timezone) + return cls( + target_id=target_dict["target_id"], + name=target_dict["name"], + processing_time_seconds=target_dict["processing_time_seconds"], + last_modified_date=last_modified_date, + upload_date=upload_date, + ) + + def to_dict(self) -> VuMarkTargetDict: + """Dump a VuMark target to a dictionary which can be loaded as + JSON. + """ return { - 'name': self.name, - 'width': self.width, - 'image_base64': image_base64, - 'active_flag': self.active_flag, - 'processing_time_seconds': self.processing_time_seconds, - 'processed_tracking_rating': self.processed_tracking_rating, - 'application_metadata': self.application_metadata, - 'target_id': self.target_id, - 'last_modified_date': self.last_modified_date.isoformat(), - 'delete_date_optional': delete_date, - 'upload_date': self.upload_date.isoformat(), + "target_id": self.target_id, + "name": self.name, + "processing_time_seconds": float(self.processing_time_seconds), + "last_modified_date": self.last_modified_date.isoformat(), + "upload_date": self.upload_date.isoformat(), } diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index f1ae715bb..14850df8e 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -1,87 +1,158 @@ -""" -A fake implementation of a Vuforia target manager. -""" +"""A fake implementation of a Vuforia target manager.""" -from typing import Set +from typing import TYPE_CHECKING -from mock_vws.database import VuforiaDatabase +from beartype import beartype +from mock_vws.database import CloudDatabase, VuMarkDatabase +if TYPE_CHECKING: + from mock_vws._database_matchers import AnyDatabase + + +@beartype class TargetManager: """ - A target manager as per https://developer.vuforia.com/target-manager. + A target manager. + + See https://developer.vuforia.com/target-manager. """ def __init__(self) -> None: - """ - Create a target manager with no databases. - """ - self._databases: Set[VuforiaDatabase] = set() + """Create a target manager with no databases.""" + self._cloud_databases: set[CloudDatabase] = set() + self._vumark_databases: set[VuMarkDatabase] = set() - def remove_database(self, database: VuforiaDatabase) -> None: - """ - Remove a cloud database. + @property + def cloud_databases(self) -> set[CloudDatabase]: + """All cloud databases.""" + return set(self._cloud_databases) + + @property + def vumark_databases(self) -> set[VuMarkDatabase]: + """All VuMark databases.""" + return set(self._vumark_databases) + + def remove_cloud_database(self, cloud_database: CloudDatabase) -> None: + """Remove a cloud database. Args: - database: The database to add. + cloud_database: The cloud database to remove. Raises: - KeyError: The database is not in the target manager. + KeyError: The cloud database is not in the target manager. """ - self._databases.remove(database) + self._cloud_databases = { + db for db in self._cloud_databases if db != cloud_database + } + + def remove_vumark_database(self, vumark_database: VuMarkDatabase) -> None: + """Remove a VuMark database. - def add_database(self, database: VuforiaDatabase) -> None: + Args: + vumark_database: The VuMark database to remove. """ - Add a cloud database. + self._vumark_databases = { + db for db in self._vumark_databases if db != vumark_database + } + + def add_cloud_database(self, cloud_database: CloudDatabase) -> None: + """Add a cloud database. Args: - database: The database to add. + cloud_database: The cloud database to add. Raises: - ValueError: One of the given database keys matches a key for an - existing database. + ValueError: One of the given cloud database keys matches a key for + an existing cloud database. """ message_fmt = ( - 'All {key_name}s must be unique. ' + "All {key_name}s must be unique. " 'There is already a database with the {key_name} "{value}".' ) - for existing_db in self.databases: + all_databases: list[AnyDatabase] = [ + *self._cloud_databases, + *self._vumark_databases, + ] + for existing_db in all_databases: for existing, new, key_name in ( ( existing_db.server_access_key, - database.server_access_key, - 'server access key', + cloud_database.server_access_key, + "server access key", ), ( existing_db.server_secret_key, - database.server_secret_key, - 'server secret key', + cloud_database.server_secret_key, + "server secret key", ), ( - existing_db.client_access_key, - database.client_access_key, - 'client access key', + existing_db.database_name, + cloud_database.database_name, + "name", ), + ): + if existing == new: + message = message_fmt.format(key_name=key_name, value=new) + raise ValueError(message) + + for existing_cloud_db in self._cloud_databases: + for existing, new, key_name in ( ( - existing_db.client_secret_key, - database.client_secret_key, - 'client secret key', + existing_cloud_db.client_access_key, + cloud_database.client_access_key, + "client access key", ), ( - existing_db.database_name, - database.database_name, - 'name', + existing_cloud_db.client_secret_key, + cloud_database.client_secret_key, + "client secret key", ), ): if existing == new: message = message_fmt.format(key_name=key_name, value=new) raise ValueError(message) - self._databases.add(database) + self._cloud_databases = {*self._cloud_databases, cloud_database} - @property - def databases(self) -> Set[VuforiaDatabase]: - """ - All cloud databases. + 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. """ - return self._databases + message_fmt = ( + "All {key_name}s must be unique. " + 'There is already a database with the {key_name} "{value}".' + ) + all_databases: list[AnyDatabase] = [ + *self._cloud_databases, + *self._vumark_databases, + ] + for existing_db in all_databases: + for existing, new, key_name in ( + ( + existing_db.server_access_key, + vumark_database.server_access_key, + "server access key", + ), + ( + existing_db.server_secret_key, + vumark_database.server_secret_key, + "server secret key", + ), + ( + existing_db.database_name, + vumark_database.database_name, + "name", + ), + ): + if existing == new: + message = message_fmt.format(key_name=key_name, value=new) + raise ValueError(message) + + self._vumark_databases = {*self._vumark_databases, vumark_database} diff --git a/src/mock_vws/target_raters.py b/src/mock_vws/target_raters.py new file mode 100644 index 000000000..bc28ce529 --- /dev/null +++ b/src/mock_vws/target_raters.py @@ -0,0 +1,114 @@ +"""Raters for target quality.""" + +import functools +import io +import math +import secrets +from typing import Protocol, runtime_checkable + +import numpy as np +import torch +from beartype import beartype +from PIL import Image +from piq.brisque import brisque # pyright: ignore[reportMissingTypeStubs] + + +@functools.cache +@beartype +def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int: + """Get a target tracking rating based on a BRISQUE score. + + This is a rough approximation of the quality score used by Vuforia, but is + not accurate. For example, our "corrupted_image" rating is based on a + BRISQUE score of 0, but Vuforia's is 1. + + Args: + image_content: A target's image's content. + """ + image_file = io.BytesIO(initial_bytes=image_content) + with Image.open(fp=image_file) as image: + image_np = np.array(object=image, dtype=np.float32) + image_tensor = ( + torch.tensor( # pyright: ignore[reportPrivateImportUsage] + data=image_np, + ).float() + / 255 + ) + image_tensor = image_tensor.view( + image.size[1], + image.size[0], + len(image.getbands()), + ) + image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(dim=0) + try: + brisque_score = brisque(x=image_tensor, data_range=255) + except AssertionError, IndexError: + return 0 + return math.ceil(int(brisque_score.item()) / 20) + + +@runtime_checkable +class TargetTrackingRater(Protocol): + """Protocol for a rater of target quality.""" + + def __call__(self, image_content: bytes) -> int: + """The target tracking rating. + + Args: + image_content: A target's image's content. + """ + # We disable a pylint warning here because the ellipsis is required + # for pyright to recognize this as a protocol. + ... # pylint: disable=unnecessary-ellipsis + + +@beartype +class RandomTargetTrackingRater: + """A rater which returns a random number.""" + + def __call__(self, image_content: bytes) -> int: + """A random target tracking rating. + + Args: + image_content: A target's image's content. + """ + del image_content + return secrets.randbelow(exclusive_upper_bound=6) + + +@beartype +class HardcodedTargetTrackingRater: + """A rater which returns a hardcoded number.""" + + def __init__(self, rating: int) -> None: + """ + Args: + rating: The rating to return. + """ + self._rating = rating + + def __call__(self, image_content: bytes) -> int: + """A random target tracking rating. + + Args: + image_content: A target's image's content. + """ + del image_content + return self._rating + + +@beartype +class BrisqueTargetTrackingRater: + """A rater which returns a rating based on a BRISQUE score.""" + + def __call__(self, image_content: bytes) -> int: + """A rating based on a BRISQUE score. + + This is a rough approximation of the quality score used by Vuforia, but + is not accurate. For example, our "corrupted_image" fixture is rated as + -2 by Vuforia, but is rated as 0 by this function. + + Args: + image_content: A target's image's content. + """ + return _get_brisque_target_tracking_rating(image_content=image_content) diff --git a/tests/conftest.py b/tests/conftest.py index 54f383b00..f1f7f33d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,4 @@ -""" -Configuration, plugins and fixtures for `pytest`. -""" +"""Configuration, plugins and fixtures for `pytest`.""" import base64 import binascii @@ -8,59 +6,70 @@ import uuid import pytest -from _pytest.fixtures import SubRequest from vws import VWS, CloudRecoService -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from tests.mock_vws.utils import Endpoint +# `credentials` must be listed before modules that import from it. +# If listed later, those imports happen before pytest can register it for +# assertion rewriting, causing a PytestAssertRewriteWarning. pytest_plugins = [ - 'tests.mock_vws.fixtures.prepared_requests', - 'tests.mock_vws.fixtures.credentials', - 'tests.mock_vws.fixtures.vuforia_backends', + "tests.mock_vws.fixtures.credentials", + "tests.mock_vws.fixtures.prepared_requests", + "tests.mock_vws.fixtures.vuforia_backends", ] -@pytest.fixture(name='vws_client') -def fixture_vws_client(vuforia_database: VuforiaDatabase) -> VWS: - """ - A VWS client for an active VWS database. - """ +@pytest.fixture(name="vws_client") +def fixture_vws_client(*, vuforia_database: CloudDatabase) -> VWS: + """A VWS client for an active VWS database.""" return VWS( server_access_key=vuforia_database.server_access_key, server_secret_key=vuforia_database.server_secret_key, ) -@pytest.fixture() -def cloud_reco_client(vuforia_database: VuforiaDatabase) -> CloudRecoService: - """ - A query client for an active VWS database. - """ +@pytest.fixture +def cloud_reco_client(*, vuforia_database: CloudDatabase) -> CloudRecoService: + """A query client for an active VWS database.""" return CloudRecoService( client_access_key=vuforia_database.client_access_key, client_secret_key=vuforia_database.client_secret_key, ) -@pytest.fixture(name='inactive_vws_client') -def fixture_inactive_vws_client(inactive_database: VuforiaDatabase) -> VWS: - """ - A client for an inactive VWS database. - """ +@pytest.fixture(name="inactive_vws_client") +def fixture_inactive_vws_client( + *, + inactive_cloud_database: CloudDatabase, +) -> VWS: + """A client for an inactive VWS database.""" return VWS( - server_access_key=inactive_database.server_access_key, - server_secret_key=inactive_database.server_secret_key, + server_access_key=inactive_cloud_database.server_access_key, + server_secret_key=inactive_cloud_database.server_secret_key, + ) + + +@pytest.fixture +def inactive_cloud_reco_client( + *, + inactive_cloud_database: CloudDatabase, +) -> CloudRecoService: + """A query client for an inactive VWS database.""" + return CloudRecoService( + client_access_key=inactive_cloud_database.client_access_key, + client_secret_key=inactive_cloud_database.client_secret_key, ) -@pytest.fixture() +@pytest.fixture def target_id( + *, image_file_success_state_low_rating: io.BytesIO, vws_client: VWS, ) -> str: - """ - Return the target ID of a target in the database. + """Return the target ID of a target in the database. The target is one which will have a 'success' status when processed. """ @@ -75,56 +84,58 @@ def target_id( @pytest.fixture( params=[ - '_add_target', - '_database_summary', - '_delete_target', - '_get_duplicates', - '_get_target', - '_target_list', - '_target_summary', - '_update_target', - '_query', + "add_target", + "database_summary", + "delete_target", + "get_duplicates", + "get_target", + "target_list", + "target_summary", + "update_target", + "query", + "vumark_generate_instance", ], ) -def endpoint(request: SubRequest) -> Endpoint: +def endpoint(*, request: pytest.FixtureRequest) -> Endpoint: """ - Return details of an endpoint for the Target API or the Query API. + Return details of an endpoint for the Target API or the Query + API. """ - endpoint_fixture: Endpoint = request.getfixturevalue(request.param) + endpoint_fixture: Endpoint = request.getfixturevalue(argname=request.param) return endpoint_fixture @pytest.fixture( params=[ pytest.param( - 'abcde', - id='Length is one more than a multiple of four.', + "abcde", + id="Length is one more than a multiple of four.", ), pytest.param( # We choose XN because it is different when decoded then encoded: # - # print(base64.b64encode(base64.b64decode('XN=='))) # # prints ``XA==``. - 'XN', - id='Length is two more than a multiple of four.', + "XN", + id="Length is two more than a multiple of four.", ), pytest.param( - 'XNA', - id='Length is three more than a multiple of four.', + "XNA", + id="Length is three more than a multiple of four.", ), ], ) -def not_base64_encoded_processable(request: SubRequest) -> str: - """ - Return a string which is not decodable as base64 data, but Vuforia will +def not_base64_encoded_processable(*, request: pytest.FixtureRequest) -> str: + """Return a string which is not decodable as base64 data, but Vuforia + will respond as if this is valid base64 data. + ``UNPROCESSABLE_ENTITY`` when this is given. """ not_base64_encoded_string: str = request.param - with pytest.raises(binascii.Error): - base64.b64decode(not_base64_encoded_string, validate=True) + with pytest.raises(expected_exception=binascii.Error): + base64.b64decode(s=not_base64_encoded_string, validate=True) return not_base64_encoded_string @@ -133,19 +144,23 @@ def not_base64_encoded_processable(request: SubRequest) -> str: params=[ pytest.param( 'aaa"', - id='Includes a character which is not a base64 digit.', + id="Includes a character which is not a base64 digit.", ), - pytest.param('"', id='Not a base64 character.'), + pytest.param('"', id="Not a base64 character."), ], ) -def not_base64_encoded_not_processable(request: SubRequest) -> str: +def not_base64_encoded_not_processable( + *, + request: pytest.FixtureRequest, +) -> str: """ - Return a string which is not decodable as base64 data, and Vuforia will + Return a string which is not decodable as base64 data, and Vuforia + will return an ``UNPROCESSABLE_ENTITY`` response when this is given. """ not_base64_encoded_string: str = request.param - with pytest.raises(binascii.Error): - base64.b64decode(not_base64_encoded_string, validate=True) + with pytest.raises(expected_exception=binascii.Error): + base64.b64decode(s=not_base64_encoded_string, validate=True) return not_base64_encoded_string diff --git a/tests/mock_vws/__init__.py b/tests/mock_vws/__init__.py index 7af10c085..55becaf36 100644 --- a/tests/mock_vws/__init__.py +++ b/tests/mock_vws/__init__.py @@ -1,7 +1,5 @@ -""" -A mock implementation of Vuforia Web Services. -""" +"""A mock implementation of Vuforia Web Services.""" import pytest -pytest.register_assert_rewrite('tests.mock_vws.utils') +pytest.register_assert_rewrite("tests.mock_vws.utils") diff --git a/tests/mock_vws/fixtures/__init__.py b/tests/mock_vws/fixtures/__init__.py index fd67d619b..3ab52191a 100644 --- a/tests/mock_vws/fixtures/__init__.py +++ b/tests/mock_vws/fixtures/__init__.py @@ -1,3 +1 @@ -""" -Common fixtures. -""" +"""Common fixtures.""" diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index 27ad5c94d..ba357b30d 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -1,44 +1,144 @@ -""" -Fixtures for credentials for Vuforia databases. -""" +"""Fixtures for credentials for Vuforia databases.""" -import os +from dataclasses import dataclass, field +from pathlib import Path +from uuid import uuid4 import pytest +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from mock_vws.states import States -@pytest.fixture() -def vuforia_database() -> VuforiaDatabase: - """ - Return VWS credentials from environment variables. - """ - credentials: VuforiaDatabase = VuforiaDatabase( - database_name=os.environ['VUFORIA_TARGET_MANAGER_DATABASE_NAME'], - server_access_key=os.environ['VUFORIA_SERVER_ACCESS_KEY'], - server_secret_key=os.environ['VUFORIA_SERVER_SECRET_KEY'], - client_access_key=os.environ['VUFORIA_CLIENT_ACCESS_KEY'], - client_secret_key=os.environ['VUFORIA_CLIENT_SECRET_KEY'], +class _CloudDatabaseSettings(BaseSettings): + """Settings for a Vuforia database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + client_access_key: str + client_secret_key: str + + model_config = SettingsConfigDict( + env_prefix="VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _InactiveCloudDatabaseSettings(_CloudDatabaseSettings): + """Settings for an inactive Vuforia database.""" + + model_config = SettingsConfigDict( + env_prefix="INACTIVE_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _InactiveVuMarkDatabaseSettings(BaseSettings): + """Settings for an inactive VuMark database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + + model_config = SettingsConfigDict( + env_prefix="INACTIVE_VUMARK_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _VuMarkCloudDatabaseSettings(BaseSettings): + """Settings for a VuMark Vuforia database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + target_id: str + processing_target_id: str = Field(default_factory=lambda: uuid4().hex) + + model_config = SettingsConfigDict( + env_prefix="VUMARK_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +@dataclass(frozen=True, kw_only=True) +class InactiveVuMarkCloudDatabase: + """Credentials for an inactive VuMark database.""" + + target_manager_database_name: str = field(repr=False) + server_access_key: str = field(repr=False) + server_secret_key: str = field(repr=False) + + +@dataclass(frozen=True, kw_only=True) +class VuMarkCloudDatabase: + """Credentials for the VuMark generation API.""" + + target_manager_database_name: str = field(repr=False) + server_access_key: str = field(repr=False) + server_secret_key: str = field(repr=False) + target_id: str = field(repr=False) + processing_target_id: str = field(repr=False) + + +@pytest.fixture +def vuforia_database() -> CloudDatabase: + """Return VWS credentials from environment variables.""" + settings = _CloudDatabaseSettings.model_validate(obj={}) + return CloudDatabase( + database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + client_access_key=settings.client_access_key, + client_secret_key=settings.client_secret_key, state=States.WORKING, ) - return credentials -@pytest.fixture() -def inactive_database() -> VuforiaDatabase: +@pytest.fixture +def inactive_cloud_database() -> CloudDatabase: """ - Return VWS credentials for an inactive project from environment variables. + Return VWS credentials for an inactive project from environment + variables. """ - credentials: VuforiaDatabase = VuforiaDatabase( - database_name=os.environ[ - 'INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME' - ], - server_access_key=os.environ['INACTIVE_VUFORIA_SERVER_ACCESS_KEY'], - server_secret_key=os.environ['INACTIVE_VUFORIA_SERVER_SECRET_KEY'], - client_access_key=os.environ['INACTIVE_VUFORIA_CLIENT_ACCESS_KEY'], - client_secret_key=os.environ['INACTIVE_VUFORIA_CLIENT_SECRET_KEY'], + settings = _InactiveCloudDatabaseSettings.model_validate(obj={}) + return CloudDatabase( + database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + client_access_key=settings.client_access_key, + client_secret_key=settings.client_secret_key, state=States.PROJECT_INACTIVE, ) - return credentials + + +@pytest.fixture +def inactive_vumark_database() -> InactiveVuMarkCloudDatabase: + """Return inactive VuMark credentials from environment variables.""" + settings = _InactiveVuMarkDatabaseSettings.model_validate(obj={}) + return InactiveVuMarkCloudDatabase( + target_manager_database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + ) + + +@pytest.fixture +def vumark_vuforia_database() -> VuMarkCloudDatabase: + """Return VuMark VWS credentials from environment variables.""" + settings = _VuMarkCloudDatabaseSettings.model_validate(obj={}) + + return VuMarkCloudDatabase( + target_manager_database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + target_id=settings.target_id, + processing_target_id=settings.processing_target_id, + ) diff --git a/tests/mock_vws/fixtures/prepared_requests.py b/tests/mock_vws/fixtures/prepared_requests.py index 74d1939f6..c3cf48669 100644 --- a/tests/mock_vws/fixtures/prepared_requests.py +++ b/tests/mock_vws/fixtures/prepared_requests.py @@ -1,50 +1,64 @@ -""" -Fixtures which prepare requests. -""" +"""Fixtures which prepare requests.""" import base64 import io import json -from http import HTTPStatus -from typing import Any, Dict -from urllib.parse import urljoin +from http import HTTPMethod, HTTPStatus +from typing import Any +from uuid import uuid4 import pytest -import requests -from requests_mock import DELETE, GET, POST, PUT +from beartype import beartype from urllib3.filepost import encode_multipart_formdata from vws import VWS from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase +from tests.mock_vws.fixtures.credentials import VuMarkCloudDatabase from tests.mock_vws.utils import Endpoint +from tests.mock_vws.utils.retries import RETRY_ON_TOO_MANY_REQUESTS -VWS_HOST = 'https://vws.vuforia.com' -VWQ_HOST = 'https://cloudreco.vuforia.com' +VWS_HOST = "https://vws.vuforia.com" +VWQ_HOST = "https://cloudreco.vuforia.com" -@pytest.fixture() -def _add_target( - vuforia_database: VuforiaDatabase, +@beartype +@RETRY_ON_TOO_MANY_REQUESTS +def _wait_for_target_processed(*, vws_client: VWS, target_id: str) -> None: + """Wait for a target to be processed. + + We retry here because pytest-retry does not retry on exceptions + raised in fixtures. + + See + https://github.com/str0zzapreti/pytest-retry/issues/33. + """ + vws_client.wait_for_target_processed(target_id=target_id) + + +@pytest.fixture +def add_target( + *, + vuforia_database: CloudDatabase, image_file_failed_state: io.BytesIO, ) -> Endpoint: - """ - Return details of the endpoint for adding a target. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """Return details of the endpoint for adding a target.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) date = rfc_1123_date() - data: Dict[str, Any] = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, + data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, } - request_path = '/targets' - content_type = 'application/json' - method = POST + request_path = "/targets" + content_type = "application/json" + method = HTTPMethod.POST - content = bytes(json.dumps(data), encoding='utf-8') + content = json.dumps(obj=data).encode(encoding="utf-8") access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -59,43 +73,38 @@ def _add_target( ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.CREATED, successful_headers_result_code=ResultCodes.TARGET_CREATED, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _delete_target( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def delete_target( + *, + vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, ) -> Endpoint: - """ - Return details of the endpoint for deleting a target. - """ - vws_client.wait_for_target_processed(target_id=target_id) + """Return details of the endpoint for deleting a target.""" + _wait_for_target_processed(vws_client=vws_client, target_id=target_id) date = rfc_1123_date() - request_path = f'/targets/{target_id}' - method = DELETE - content = b'' + request_path = f"/targets/{target_id}" + method = HTTPMethod.DELETE + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -104,43 +113,41 @@ def _delete_target( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), } - request = requests.Request( + return Endpoint( + base_url=VWS_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWS_HOST, url=request_path), headers=headers, data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _database_summary(vuforia_database: VuforiaDatabase) -> Endpoint: +@pytest.fixture +def database_summary(*, vuforia_database: CloudDatabase) -> Endpoint: """ - Return details of the endpoint for getting details about the database. + Return details of the endpoint for getting details about the + database. """ date = rfc_1123_date() - request_path = '/summary' - method = GET + request_path = "/summary" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -149,37 +156,34 @@ def _database_summary(vuforia_database: VuforiaDatabase) -> Endpoint: secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( + return Endpoint( + base_url=VWS_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWS_HOST, url=request_path), headers=headers, data=content, - ) - - prepared_request = request.prepare() - - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _get_duplicates( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def get_duplicates( + *, + vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, ) -> Endpoint: @@ -187,12 +191,12 @@ def _get_duplicates( Return details of the endpoint for getting potential duplicates of a target. """ - vws_client.wait_for_target_processed(target_id=target_id) + _wait_for_target_processed(vws_client=vws_client, target_id=target_id) date = rfc_1123_date() - request_path = f'/duplicates/{target_id}' - method = GET + request_path = f"/duplicates/{target_id}" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -201,49 +205,44 @@ def _get_duplicates( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( + return Endpoint( + base_url=VWS_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWS_HOST, url=request_path), headers=headers, data=content, - ) - - prepared_request = request.prepare() - - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _get_target( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def get_target( + *, + vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, ) -> Endpoint: - """ - Return details of the endpoint for getting details of a target. - """ - vws_client.wait_for_target_processed(target_id=target_id) + """Return details of the endpoint for getting details of a target.""" + _wait_for_target_processed(vws_client=vws_client, target_id=target_id) date = rfc_1123_date() - request_path = f'/targets/{target_id}' - method = GET + request_path = f"/targets/{target_id}" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -252,44 +251,38 @@ def _get_target( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _target_list(vuforia_database: VuforiaDatabase) -> Endpoint: - """ - Return details of the endpoint for getting a list of targets. - """ +@pytest.fixture +def target_list(*, vuforia_database: CloudDatabase) -> Endpoint: + """Return details of the endpoint for getting a list of targets.""" date = rfc_1123_date() - request_path = '/targets' - method = GET + request_path = "/targets" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -298,49 +291,47 @@ def _target_list(vuforia_database: VuforiaDatabase) -> Endpoint: secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _target_summary( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def target_summary( + *, + vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, ) -> Endpoint: """ - Return details of the endpoint for getting a summary report of a target. + Return details of the endpoint for getting a summary report of a + target. """ - vws_client.wait_for_target_processed(target_id=target_id) + _wait_for_target_processed(vws_client=vws_client, target_id=target_id) date = rfc_1123_date() - request_path = f'/summary/{target_id}' - method = GET + request_path = f"/summary/{target_id}" + method = HTTPMethod.GET - content = b'' + content = b"" access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -349,51 +340,46 @@ def _target_summary( secret_key=secret_key, method=method, content=content, - content_type='', + content_type="", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _update_target( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def update_target( + *, + vuforia_database: CloudDatabase, target_id: str, vws_client: VWS, ) -> Endpoint: - """ - Return details of the endpoint for updating a target. - """ - vws_client.wait_for_target_processed(target_id=target_id) - data: Dict[str, Any] = {} - request_path = f'/targets/{target_id}' - content = bytes(json.dumps(data), encoding='utf-8') - content_type = 'application/json' + """Return details of the endpoint for updating a target.""" + _wait_for_target_processed(vws_client=vws_client, target_id=target_id) + data: dict[str, Any] = {} + request_path = f"/targets/{target_id}" + content = json.dumps(obj=data).encode(encoding="utf-8") + content_type = "application/json" date = rfc_1123_date() - method = PUT + method = HTTPMethod.PUT access_key = vuforia_database.server_access_key secret_key = vuforia_database.server_secret_key @@ -408,44 +394,42 @@ def _update_target( ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, } - request = requests.Request( - method=method, - url=urljoin(base=VWS_HOST, url=request_path), - headers=headers, - data=content, - ) - - prepared_request = request.prepare() - return Endpoint( successful_headers_status_code=HTTPStatus.OK, successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) -@pytest.fixture() -def _query( - vuforia_database: VuforiaDatabase, +@pytest.fixture +def query( + *, + vuforia_database: CloudDatabase, high_quality_image: io.BytesIO, ) -> Endpoint: """ - Return details of the endpoint for making an image recognition query. + Return details of the endpoint for making an image recognition + query. """ - image_content = high_quality_image.read() + image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - files = {'image': ('image.jpeg', image_content, 'image/jpeg')} - method = POST + request_path = "/v1/query" + files = {"image": ("image.jpeg", image_content, "image/jpeg")} + method = HTTPMethod.POST - content, content_type_header = encode_multipart_formdata(files) + content, content_type_header = encode_multipart_formdata(fields=files) access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -455,30 +439,73 @@ def _query( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, + "Content-Type": content_type_header, } - request = requests.Request( + return Endpoint( + successful_headers_status_code=HTTPStatus.OK, + successful_headers_result_code=ResultCodes.SUCCESS, + base_url=VWQ_HOST, + path_url=request_path, method=method, - url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + access_key=access_key, + secret_key=secret_key, ) - prepared_request = request.prepare() + +@pytest.fixture +def vumark_generate_instance( + *, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> Endpoint: + """Return details of the endpoint for generating a VuMark instance.""" + request_path = f"/targets/{vumark_vuforia_database.target_id}/instances" + content_type = "application/json" + method = HTTPMethod.POST + content = json.dumps(obj={"instance_id": uuid4().hex}).encode( + encoding="utf-8" + ) + date = rfc_1123_date() + + access_key = vumark_vuforia_database.server_access_key + secret_key = vumark_vuforia_database.server_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + headers = { + "Accept": "image/png", + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + } return Endpoint( successful_headers_status_code=HTTPStatus.OK, - successful_headers_result_code=ResultCodes.SUCCESS, - prepared_request=prepared_request, + successful_headers_result_code=None, + base_url=VWS_HOST, + path_url=request_path, + method=method, + headers=headers, + data=content, access_key=access_key, secret_key=secret_key, ) diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 375a31b00..adb281569 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -1,35 +1,41 @@ -""" -Choose which backends to use for the tests. -""" +"""Choose which backends to use for the tests.""" +import contextlib import logging -import os +from collections.abc import Generator from enum import Enum -from typing import Generator import pytest import requests -import requests_mock -from _pytest.fixtures import SubRequest -from pytest import MonkeyPatch +import responses +from beartype import beartype from requests_mock_flask import add_flask_app_to_mock from vws import VWS -from vws.exceptions.vws_exceptions import TargetStatusNotSuccess +from vws.exceptions.vws_exceptions import ( + TargetStatusNotSuccessError, +) from mock_vws import MockVWS from mock_vws._flask_server.target_manager import TARGET_MANAGER_FLASK_APP from mock_vws._flask_server.vwq import CLOUDRECO_FLASK_APP from mock_vws._flask_server.vws import VWS_FLASK_APP -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.states import States +from mock_vws.target import VuMarkTarget +from tests.mock_vws.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, + VuMarkCloudDatabase, +) +from tests.mock_vws.utils.retries import RETRY_ON_TOO_MANY_REQUESTS -LOGGER = logging.getLogger(__name__) -LOGGER.setLevel(logging.DEBUG) +LOGGER = logging.getLogger(name=__name__) +LOGGER.setLevel(level=logging.DEBUG) -def _delete_all_targets(database_keys: VuforiaDatabase) -> None: - """ - Delete all targets. +@beartype +@RETRY_ON_TOO_MANY_REQUESTS +def _delete_all_targets(*, database_keys: CloudDatabase) -> None: + """Delete all targets. Args: database_keys: The credentials to the Vuforia target database to delete @@ -43,35 +49,75 @@ def _delete_all_targets(database_keys: VuforiaDatabase) -> None: targets = vws_client.list_targets() for target in targets: - vws_client.wait_for_target_processed(target_id=target) + vws_client.wait_for_target_processed( + target_id=target, + # Setting this to 2 is an attempt to avoid 429 Too Many Requests + # errors. + seconds_between_requests=2, + ) # Even deleted targets can be matched by a query for a few seconds so # we change the target to inactive before deleting it. - try: + with contextlib.suppress(TargetStatusNotSuccessError): vws_client.update_target(target_id=target, active_flag=False) - except TargetStatusNotSuccess: - pass vws_client.wait_for_target_processed(target_id=target) vws_client.delete_target(target_id=target) +@beartype +def _vumark_database( + *, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> VuMarkDatabase: + """Return a database with a VuMark target for VuMark instance + generation. + """ + vumark_target = VuMarkTarget( + name="mock-vumark-target", + target_id=vumark_vuforia_database.target_id, + ) + processing_target = VuMarkTarget( + name="mock-processing-vumark-target", + target_id=vumark_vuforia_database.processing_target_id, + processing_time_seconds=9999, + ) + return VuMarkDatabase( + database_name=vumark_vuforia_database.target_manager_database_name, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + vumark_targets={vumark_target, processing_target}, + ) + + +@beartype def _enable_use_real_vuforia( - working_database: VuforiaDatabase, - inactive_database: VuforiaDatabase, - monkeypatch: MonkeyPatch, -) -> Generator: + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the real Vuforia.""" assert monkeypatch - assert inactive_database + assert inactive_cloud_database + assert vumark_vuforia_database + assert inactive_vumark_database _delete_all_targets(database_keys=working_database) yield +@beartype def _enable_use_mock_vuforia( - working_database: VuforiaDatabase, - inactive_database: VuforiaDatabase, - monkeypatch: MonkeyPatch, -) -> Generator: + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the in-memory mock Vuforia.""" assert monkeypatch - working_database = VuforiaDatabase( + working_database = CloudDatabase( database_name=working_database.database_name, server_access_key=working_database.server_access_key, server_secret_key=working_database.server_secret_key, @@ -79,28 +125,44 @@ def _enable_use_mock_vuforia( client_secret_key=working_database.client_secret_key, ) - inactive_database = VuforiaDatabase( + inactive_cloud_database = CloudDatabase( + state=States.PROJECT_INACTIVE, + database_name=inactive_cloud_database.database_name, + server_access_key=inactive_cloud_database.server_access_key, + server_secret_key=inactive_cloud_database.server_secret_key, + client_access_key=inactive_cloud_database.client_access_key, + client_secret_key=inactive_cloud_database.client_secret_key, + ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) + inactive_vumark_db = VuMarkDatabase( state=States.PROJECT_INACTIVE, - database_name=inactive_database.database_name, - server_access_key=inactive_database.server_access_key, - server_secret_key=inactive_database.server_secret_key, - client_access_key=inactive_database.client_access_key, - client_secret_key=inactive_database.client_secret_key, + database_name=inactive_vumark_database.target_manager_database_name, + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, ) - with MockVWS(processing_time_seconds=0.2) as mock: - mock.add_database(database=working_database) - mock.add_database(database=inactive_database) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=working_database) + mock.add_cloud_database(cloud_database=inactive_cloud_database) + mock.add_vumark_database(vumark_database=vumark_database) + mock.add_vumark_database(vumark_database=inactive_vumark_db) yield +@beartype def _enable_use_docker_in_memory( - working_database: VuforiaDatabase, - inactive_database: VuforiaDatabase, - monkeypatch: MonkeyPatch, -) -> Generator: + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against mock Vuforia created to be run in a container.""" # We set ``wsgi.input_terminated`` to ``True`` so that when going through - # ``requests``, the Flask applications + # ``requests`` in our tests, the Flask applications # have the given ``Content-Length`` headers and the given data in # ``request.headers`` and ``request.data``. # @@ -111,26 +173,35 @@ def _enable_use_docker_in_memory( # Therefore, when running the real Flask application, the behavior is not # the same as the real Vuforia. # This is documented as a difference in the documentation for this package. - VWS_FLASK_APP.config['TERMINATE_WSGI_INPUT'] = True - CLOUDRECO_FLASK_APP.config['TERMINATE_WSGI_INPUT'] = True + VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + CLOUDRECO_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True - target_manager_base_url = 'http://example.com' + target_manager_base_url = "http://example.com" monkeypatch.setenv( - name='TARGET_MANAGER_BASE_URL', + name="TARGET_MANAGER_BASE_URL", value=target_manager_base_url, ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) + inactive_vumark_db = VuMarkDatabase( + state=States.PROJECT_INACTIVE, + database_name=inactive_vumark_database.target_manager_database_name, + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + ) - with requests_mock.Mocker(real_http=False) as mock: + with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: add_flask_app_to_mock( mock_obj=mock, flask_app=VWS_FLASK_APP, - base_url='https://vws.vuforia.com', + base_url="https://vws.vuforia.com", ) add_flask_app_to_mock( mock_obj=mock, flask_app=CLOUDRECO_FLASK_APP, - base_url='https://cloudreco.vuforia.com', + base_url="https://cloudreco.vuforia.com", ) add_flask_app_to_mock( @@ -139,57 +210,196 @@ def _enable_use_docker_in_memory( base_url=target_manager_base_url, ) - databases_url = target_manager_base_url + '/databases' - databases = requests.get(url=databases_url).json() - for database in databases: - database_name = database['database_name'] - requests.delete(url=databases_url + '/' + database_name) - - requests.post(url=databases_url, json=working_database.to_dict()) - requests.post(url=databases_url, json=inactive_database.to_dict()) + cloud_databases_url = target_manager_base_url + "/cloud_databases" + vumark_databases_url = target_manager_base_url + "/vumark_databases" + + for database in requests.get( + url=cloud_databases_url, timeout=30 + ).json(): + requests.delete( + url=cloud_databases_url + "/" + database["database_name"], + timeout=30, + ) + for database in requests.get( + url=vumark_databases_url, timeout=30 + ).json(): + requests.delete( + url=vumark_databases_url + "/" + database["database_name"], + timeout=30, + ) + + requests.post( + url=cloud_databases_url, + json=working_database.to_dict(), + timeout=30, + ) + requests.post( + url=cloud_databases_url, + json=inactive_cloud_database.to_dict(), + timeout=30, + ) + requests.post( + url=vumark_databases_url, + json=vumark_database.to_dict(), + timeout=30, + ) + requests.post( + url=vumark_databases_url, + json=inactive_vumark_db.to_dict(), + timeout=30, + ) + for vumark_target in vumark_database.vumark_targets: + requests.post( + url=( + f"{vumark_databases_url}" + f"/{vumark_database.database_name}/vumark_targets" + ), + json=vumark_target.to_dict(), + timeout=30, + ) yield class VuforiaBackend(Enum): + """Backends for tests.""" + + REAL = "Real Vuforia" + MOCK = "In Memory Mock Vuforia" + DOCKER_IN_MEMORY = "In Memory version of Docker application" + + +@beartype +def pytest_addoption(parser: pytest.Parser) -> None: """ - Backends for tests. + Add options to the pytest command line for skipping tests with + particular + backends. """ + for backend in VuforiaBackend: + parser.addoption( + f"--skip-{backend.name.lower()}", + action="store_true", + default=False, + help=f"Skip tests for {backend.value}", + ) + + parser.addoption( + "--skip-docker_build_tests", + action="store_true", + default=False, + help="Skip tests for building Docker images", + ) + - REAL = 'Real Vuforia' - MOCK = 'In Memory Mock Vuforia' - DOCKER_IN_MEMORY = 'In Memory version of Docker application' +@beartype +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + """Skip Docker tests if requested.""" + skip_docker_build_tests_option = "--skip-docker_build_tests" + skip_docker_build_tests_marker = pytest.mark.skip( + reason=( + "Skipping docker build tests because " + f"{skip_docker_build_tests_option} was set" + ), + ) + if config.getoption(name=skip_docker_build_tests_option): + for item in items: + if "requires_docker_build" in item.keywords: + item.add_marker(marker=skip_docker_build_tests_marker) @pytest.fixture( + name="verify_mock_vuforia", params=list(VuforiaBackend), ids=[backend.value for backend in list(VuforiaBackend)], ) -def verify_mock_vuforia( - request: SubRequest, - vuforia_database: VuforiaDatabase, - inactive_database: VuforiaDatabase, - monkeypatch: MonkeyPatch, -) -> Generator: +def fixture_verify_mock_vuforia( + *, + request: pytest.FixtureRequest, + vuforia_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test functions which use this fixture are run multiple times. Once + with + the real Vuforia, and once with each mock. + + This is useful for verifying the mocks. + + Yields: + ``None``. """ - Test functions which use this fixture are run twice. Once with the real - Vuforia, and once with the mock. + backend: VuforiaBackend = request.param + should_skip = request.config.getoption( + name=f"--skip-{backend.name.lower()}", + ) + if should_skip: + pytest.skip() + + enable_function = { + VuforiaBackend.REAL: _enable_use_real_vuforia, + VuforiaBackend.MOCK: _enable_use_mock_vuforia, + VuforiaBackend.DOCKER_IN_MEMORY: _enable_use_docker_in_memory, + }[backend] + + yield from enable_function( + working_database=vuforia_database, + inactive_cloud_database=inactive_cloud_database, + vumark_vuforia_database=vumark_vuforia_database, + inactive_vumark_database=inactive_vumark_database, + monkeypatch=monkeypatch, + ) + - This is useful for verifying the mock. +@pytest.fixture( + params=[item for item in VuforiaBackend if item != VuforiaBackend.REAL], + ids=[ + backend.value + for backend in [ + item for item in VuforiaBackend if item != VuforiaBackend.REAL + ] + ], +) +def mock_only_vuforia( + *, + request: pytest.FixtureRequest, + vuforia_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test functions which use this fixture are run multiple times. Once + with + the each mock. + + This is useful for testing the mock using fixtures which connect to + Vuforia. + + Yields: + ``None``. """ - backend = request.param - should_skip = bool(os.getenv(f'SKIP_{backend.name}') == '1') - if should_skip: # pragma: no cover + backend: VuforiaBackend = request.param + should_skip = request.config.getoption( + name=f"--skip-{backend.name.lower()}", + ) + if should_skip: pytest.skip() enable_function = { - VuforiaBackend.REAL: _enable_use_real_vuforia, VuforiaBackend.MOCK: _enable_use_mock_vuforia, VuforiaBackend.DOCKER_IN_MEMORY: _enable_use_docker_in_memory, }[backend] yield from enable_function( working_database=vuforia_database, - inactive_database=inactive_database, + inactive_cloud_database=inactive_cloud_database, + vumark_vuforia_database=vumark_vuforia_database, + inactive_vumark_database=inactive_vumark_database, monkeypatch=monkeypatch, ) diff --git a/tests/mock_vws/jetty_error_array_out_of_bounds.html b/tests/mock_vws/jetty_error_array_out_of_bounds.html deleted file mode 100644 index f5fcfa169..000000000 --- a/tests/mock_vws/jetty_error_array_out_of_bounds.html +++ /dev/null @@ -1,19 +0,0 @@ - - - -Error 500 java.lang.ArrayIndexOutOfBoundsException - -

HTTP ERROR 500 java.lang.ArrayIndexOutOfBoundsException

- - - - - - -
URI:/v1/query
STATUS:500
MESSAGE:java.lang.ArrayIndexOutOfBoundsException
SERVLET:Resteasy
CAUSED BY:java.lang.ArrayIndexOutOfBoundsException
-

Caused by:

java.lang.ArrayIndexOutOfBoundsException
-
-
Powered by Jetty:// 9.4.43.v20210629
- - - diff --git a/tests/mock_vws/jetty_error_array_out_of_bounds_2.html b/tests/mock_vws/jetty_error_array_out_of_bounds_2.html deleted file mode 100644 index 0d244ed16..000000000 --- a/tests/mock_vws/jetty_error_array_out_of_bounds_2.html +++ /dev/null @@ -1,54 +0,0 @@ - - - -Error 500 java.lang.ArrayIndexOutOfBoundsException: 1 - -

HTTP ERROR 500 java.lang.ArrayIndexOutOfBoundsException: 1

- - - - - - -
URI:/v1/query
STATUS:500
MESSAGE:java.lang.ArrayIndexOutOfBoundsException: 1
SERVLET:Resteasy
CAUSED BY:java.lang.ArrayIndexOutOfBoundsException: 1
-

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:1630)
-	at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:567)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
-	at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:602)
-	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:127)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:235)
-	at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:1610)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:233)
-	at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1377)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:188)
-	at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:507)
-	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1580)
-	at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:186)
-	at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1292)
-	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
-	at org.eclipse.jetty.server.handler.ContextHandlerCollection.handle(ContextHandlerCollection.java:191)
-	at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:146)
-	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:127)
-	at org.eclipse.jetty.server.Server.handle(Server.java:501)
-	at org.eclipse.jetty.server.HttpChannel.lambda$handle$1(HttpChannel.java:383)
-	at org.eclipse.jetty.server.HttpChannel.dispatch(HttpChannel.java:556)
-	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:375)
-	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:273)
-	at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:311)
-	at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:105)
-	at org.eclipse.jetty.io.ChannelEndPoint$1.run(ChannelEndPoint.java:104)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.runTask(EatWhatYouKill.java:336)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.doProduce(EatWhatYouKill.java:313)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.tryProduce(EatWhatYouKill.java:171)
-	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.run(EatWhatYouKill.java:129)
-	at org.eclipse.jetty.util.thread.ReservedThreadExecutor$ReservedThread.run(ReservedThreadExecutor.java:375)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:806)
-	at org.eclipse.jetty.util.thread.QueuedThreadPool$Runner.run(QueuedThreadPool.java:938)
-	at java.lang.Thread.run(Thread.java:748)
-
-
Powered by Jetty:// 9.4.43.v20210629
- - - diff --git a/tests/mock_vws/jetty_error_deletion_not_complete.html b/tests/mock_vws/jetty_error_deletion_not_complete.html deleted file mode 100644 index e50cc2bc7..000000000 --- a/tests/mock_vws/jetty_error_deletion_not_complete.html +++ /dev/null @@ -1,13 +0,0 @@ - - - -Error 500 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] - -

HTTP ERROR 500 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]

- - - - diff --git a/tests/mock_vws/test_add_target.py b/tests/mock_vws/test_add_target.py index f627b5c1e..1703004ee 100644 --- a/tests/mock_vws/test_add_target.py +++ b/tests/mock_vws/test_add_target.py @@ -1,106 +1,68 @@ -""" -Tests for the mock of the add target endpoint. -""" - -from __future__ import annotations +"""Tests for the mock of the add target endpoint.""" import base64 import io import json -from http import HTTPStatus +from http import HTTPMethod, HTTPStatus from string import hexdigits -from typing import Any, Dict -from urllib.parse import urljoin +from typing import Any, Final import pytest -import requests -from requests import Response -from requests_mock import POST +from beartype import beartype from vws import VWS -from vws_auth_tools import authorization_header, rfc_1123_date +from vws.exceptions.custom_exceptions import ( + ServerError, +) +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + BadImageError, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectInactiveError, + TargetNameExistError, +) +from vws.response import Response from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase from tests.mock_vws.utils import make_image_file from tests.mock_vws.utils.assertions import ( - assert_valid_date_header, assert_vws_failure, assert_vws_response, ) +_MAX_METADATA_BYTES: Final[int] = 1024 * 1024 - 1 + -def add_target_to_vws( - vuforia_database: VuforiaDatabase, - data: Dict[str, Any], - content_type: str = 'application/json', +@beartype +def _add_target_to_vws( + *, + vws_client: VWS, + data: dict[str, Any], + content_type: str = "application/json", ) -> Response: - """ - Return a response from a request to the endpoint to add a target. + """Return a response from a request to the endpoint to add a target. Args: - vuforia_database: The credentials to use to connect to Vuforia. + vws_client: The client to use to connect to Vuforia. data: The data to send, in JSON format, to the endpoint. content_type: The `Content-Type` header to use. Returns: The response returned by the API. """ - date = rfc_1123_date() - request_path = '/targets' - - content = bytes(json.dumps(data), encoding='utf-8') - - authorization_string = authorization_header( - access_key=vuforia_database.server_access_key, - secret_key=vuforia_database.server_secret_key, - method=POST, - content=content, - content_type=content_type, - date=date, - request_path=request_path, - ) - - headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, - } - - response = requests.request( - method=POST, - url=urljoin(base='https://vws.vuforia.com/', url=request_path), - headers=headers, + content = json.dumps(obj=data).encode(encoding="utf-8") + return vws_client.make_request( + method=HTTPMethod.POST, data=content, + request_path="/targets", + expected_result_code=ResultCodes.TARGET_CREATED.value, + content_type=content_type, ) - return response - - -def _assert_oops_response(response: Response) -> None: - """ - Assert that the response is in the format of Vuforia's "Oops, an error - occurred" HTML response. - - Raises: - AssertionError: The given response is not in the expected format. - """ - assert_valid_date_header(response=response) - assert 'Oops, an error occurred' in response.text - assert 'This exception has been logged with id' in response.text - - expected_headers = { - 'Content-Type': 'text/html; charset=UTF-8', - 'Date': response.headers['Date'], - 'Server': 'nginx', - 'Content-Length': '1172', - 'Connection': 'keep-alive', - } - assert dict(response.headers) == expected_headers - def assert_success(response: Response) -> None: - """ - Assert that the given response is a success response for adding a + """Assert that the given response is a success response for adding a target. Raises: @@ -112,246 +74,231 @@ def assert_success(response: Response) -> None: status_code=HTTPStatus.CREATED, result_code=ResultCodes.TARGET_CREATED, ) - expected_keys = {'result_code', 'transaction_id', 'target_id'} - assert response.json().keys() == expected_keys - target_id = response.json()['target_id'] - assert len(target_id) == 32 + expected_keys = {"result_code", "transaction_id", "target_id"} + response_json = json.loads(s=response.text) + target_id = response_json["target_id"] + expected_target_id_length = 32 + assert len(target_id) == expected_target_id_length assert all(char in hexdigits for char in target_id) + assert isinstance(response_json, dict) + assert response_json.keys() == expected_keys -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestContentTypes: - """ - Tests for the `Content-Type` header. - """ + """Tests for the `Content-Type` header.""" + @staticmethod @pytest.mark.parametrize( - 'content_type', - [ + argnames="content_type", + argvalues=[ # This is the documented required content type: - 'application/json', + "application/json", # Other content types also work. - 'other/content_type', + "other/content_type", ], ids=[ - 'Documented Content-Type', - 'Undocumented Content-Type', + "Documented Content-Type", + "Undocumented Content-Type", ], ) def test_content_types( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, content_type: str, ) -> None: - """ - Any non-empty ``Content-Type`` header is allowed. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """Any non-empty ``Content-Type`` header is allowed.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, + "name": "example", + "width": 1, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, + response = _add_target_to_vws( + vws_client=vws_client, data=data, content_type=content_type, ) assert_success(response=response) + @staticmethod def test_empty_content_type( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - An ``UNAUTHORIZED`` response is given if an empty ``Content-Type`` + An ``UNAUTHORIZED`` response is given if an empty ``Content- + Type`` header is given. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, + "name": "example", + "width": 1, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='', - ) + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNAUTHORIZED, result_code=ResultCodes.AUTHENTICATION_FAILURE, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMissingData: - """ - Tests for giving incomplete data. - """ + """Tests for giving incomplete data.""" - @pytest.mark.parametrize('data_to_remove', ['name', 'width', 'image']) + @staticmethod + @pytest.mark.parametrize( + argnames="data_to_remove", + argvalues=["name", "width", "image"], + ) def test_missing_data( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, data_to_remove: str, ) -> None: - """ - `name`, `width` and `image` are all required. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """`name`, `width` and `image` are all required.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, + "name": "example_name", + "width": 1, + "image": image_data_encoded, } data.pop(data_to_remove) - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestWidth: - """ - Tests for the target width field. - """ + """Tests for the target width field.""" + @staticmethod @pytest.mark.parametrize( - 'width', - [-1, '10', None, 0], - ids=['Negative', 'Wrong Type', 'None', 'Zero'], + argnames="width", + argvalues=[-1, "10", None, 0], + ids=["Negative", "Wrong Type", "None", "Zero"], ) def test_width_invalid( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, - width: Any, + width: int | str | None, ) -> None: - """ - The width must be a number greater than zero. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """The width must be a number greater than zero.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example_name', - 'width': width, - 'image': image_data_encoded, + "name": "example_name", + "width": width, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_width_valid( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - Positive numbers are valid widths. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example', - 'width': 0.01, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='application/json', + """Positive numbers are valid widths.""" + vws_client.add_target( + name="example", + width=0.01, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetName: - """ - Tests for the target name field. - """ + """Tests for the target name field.""" _MAX_CHAR_VALUE = 65535 _MAX_NAME_LENGTH = 64 + @staticmethod @pytest.mark.parametrize( - 'name', - [ - 'á', + argnames="name", + argvalues=[ + "á", # We test just below the max character value. # This is because targets with the max character value in their # names get stuck in the processing stage. chr(_MAX_CHAR_VALUE - 2), - 'a' * _MAX_NAME_LENGTH, + "a" * _MAX_NAME_LENGTH, ], - ids=['Short name', 'Max char value', 'Long name'], + ids=["Short name", "Max char value", "Long name"], ) def test_name_valid( - self, + *, name: str, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: - """ - Names between 1 and 64 characters in length are valid. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': name, - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='application/json', + """Names between 1 and 64 characters in length are valid.""" + vws_client.add_target( + name=name, + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - + @staticmethod @pytest.mark.parametrize( - 'name,status_code', - [ + argnames=("name", "status_code"), + argvalues=[ (1, HTTPStatus.BAD_REQUEST), - ('', HTTPStatus.BAD_REQUEST), - ('a' * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST), + ("", HTTPStatus.BAD_REQUEST), + ("a" * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST), (None, HTTPStatus.BAD_REQUEST), (chr(_MAX_CHAR_VALUE + 1), HTTPStatus.INTERNAL_SERVER_ERROR), ( @@ -360,228 +307,201 @@ def test_name_valid( ), ], ids=[ - 'Wrong Type', - 'Empty', - 'Too Long', - 'None', - 'Bad char', - 'Bad char too long', + "Wrong Type", + "Empty", + "Too Long", + "None", + "Bad char", + "Bad char too long", ], ) def test_name_invalid( - self, - name: str, + *, + name: str | int | None, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, status_code: int, + vws_client: VWS, ) -> None: """ - A target's name must be a string of length 0 < N < 65, with characters + A target's name must be a string of length 0 < N < 65, with + characters in a particular range. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) data = { - 'name': name, - 'width': 1, - 'image': image_data_encoded, + "name": name, + "width": 1, + "image": image_data_encoded, + "application_metadata": None, + "active_flag": True, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) - - assert response.status_code == status_code + exc: pytest.ExceptionInfo[FailError | ServerError] if status_code == HTTPStatus.INTERNAL_SERVER_ERROR: - _assert_oops_response(response=response) - return + with pytest.raises(expected_exception=ServerError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) + else: + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=status_code, result_code=ResultCodes.FAIL, ) + @staticmethod def test_existing_target_name( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: - """ - Only one target can have a given name. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + """Only one target can have a given name.""" + vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + with pytest.raises(expected_exception=TargetNameExistError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.TARGET_NAME_EXIST, ) + @staticmethod def test_deleted_existing_target_name( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - A target can be added with the name of a deleted target. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + """A target can be added with the name of a deleted target.""" + target_id = vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - target_id = response.json()['target_id'] - vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestImage: - """ - Tests for the image parameter. + """Tests for the image parameter. The specification for images is documented at https://library.vuforia.com/features/images/image-targets.html. """ + @staticmethod def test_image_valid( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_files_failed_state: io.BytesIO, ) -> None: """ - JPEG and PNG files in the RGB and greyscale color spaces are allowed. + JPEG and PNG files in the RGB and greyscale color spaces are + allowed. """ - image_file = image_files_failed_state - image_data = image_file.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type='application/json', + vws_client.add_target( + name="example_name", + width=1, + image=image_files_failed_state, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - + @staticmethod def test_bad_image_format_or_color_space( - self, + *, bad_image_file: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: """ - An `UNPROCESSABLE_ENTITY` response is returned if an image which is not + An `UNPROCESSABLE_ENTITY` response is returned if an image which + is not a JPEG or PNG file is given, or if the given image is not in the greyscale or RGB color space. """ - image_data = bad_image_file.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=bad_image_file, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod def test_corrupted( - self, - vuforia_database: VuforiaDatabase, + *, corrupted_image_file: io.BytesIO, + vws_client: VWS, ) -> None: - """ - No error is returned when the given image is corrupted. - """ - image_data = corrupted_image_file.getvalue() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } + """An error is returned when the given image is corrupted.""" + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=corrupted_image_file, + application_metadata=None, + active_flag=True, + ) - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.BAD_IMAGE, ) - assert_success(response=response) - - def test_image_file_size_too_large( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_image_file_size_too_large(vws_client: VWS) -> None: """ - An ``ImageTooLarge`` result is returned if the image file size is above - a certain threshold. + An ``ImageTooLargeError`` result is returned if the image file + size is + above a certain threshold. """ max_bytes = 2.3 * 1024 * 1024 width = height = 886 png_not_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) - image_data = png_not_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_not_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -591,30 +511,24 @@ def test_image_file_size_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + vws_client.add_target( + name="example_name", + width=1, + image=png_not_too_large, + application_metadata=None, + active_flag=True, ) - assert_success(response=response) - width = width + 1 height = height + 1 png_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) - image_data = png_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -624,512 +538,482 @@ def test_image_file_size_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - data = { - 'name': 'example_name_2', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name_2", + width=1, + image=png_too_large, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.IMAGE_TOO_LARGE, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, not_base64_encoded_processable: str, ) -> None: - """ - Some strings which are not valid base64 encoded strings are allowed as - an image without getting a "Fail" response. - This is because Vuforia treats them as valid base64, but then not a - valid image. + """Some strings which are not valid base64 encoded strings are + allowed + as an image without getting a "Fail" response. + + This is because Vuforia treats them as valid base64, but then + not a valid image. """ data = { - 'name': 'example_name', - 'width': 1, - 'image': not_base64_encoded_processable, + "name": "example_name", + "width": 1, + "image": not_base64_encoded_processable, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, not_base64_encoded_not_processable: str, ) -> None: """ Some strings which are not valid base64 encoded strings are not - processable by Vuforia, and then when given as an image Vuforia returns + processable by Vuforia, and then when given as an image Vuforia + returns a "Fail" response. """ data = { - 'name': 'example_name', - 'width': 1, - 'image': not_base64_encoded_not_processable, + "name": "example_name", + "width": 1, + "image": not_base64_encoded_not_processable, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) - def test_not_image( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_not_image(vws_client: VWS) -> None: """ - If the given image is not an image file then a `BadImage` result is - returned. + If the given image is not an image file then a `BadImageError` + result + is returned. """ - not_image_data = b'not_image_data' - image_data_encoded = base64.b64encode(not_image_data).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=io.BytesIO(initial_bytes=b"not_image_data"), + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) - @pytest.mark.parametrize('invalid_type_image', [1, None]) + @staticmethod + @pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], + ) def test_invalid_type( - self, - invalid_type_image: Any, - vuforia_database: VuforiaDatabase, + *, + invalid_type_image: int | None, + vws_client: VWS, ) -> None: - """ - If the given image is not a string, a `Fail` result is returned. - """ + """If the given image is not a string, a `Fail` result is returned.""" data = { - 'name': 'example_name', - 'width': 1, - 'image': invalid_type_image, + "name": "example_name", + "width": 1, + "image": invalid_type_image, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for the active flag parameter. - """ + """Tests for the active flag parameter.""" - @pytest.mark.parametrize('active_flag', [True, False, None]) + @staticmethod + @pytest.mark.parametrize( + argnames="active_flag", + argvalues=[True, False, None], + ) def test_valid( - self, + *, active_flag: bool | None, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: - """ - Boolean values and NULL are valid active flags. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - content_type = 'application/json' + """Boolean values and NULL are valid active flags.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + content_type = "application/json" data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - 'active_flag': active_flag, + "name": "example", + "width": 1, + "image": image_data_encoded, + "active_flag": active_flag, } - response = add_target_to_vws( - vuforia_database=vuforia_database, + response = _add_target_to_vws( + vws_client=vws_client, data=data, content_type=content_type, ) assert_success(response=response) + @staticmethod def test_invalid( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: """ - Values which are not Boolean values or NULL are not valid active flags. + Values which are not Boolean values or NULL are not valid active + flags. """ - active_flag = 'string' - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - content_type = 'application/json' + active_flag = "string" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + content_type = "application/json" data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - 'active_flag': active_flag, + "name": "example", + "width": 1, + "image": image_data_encoded, + "active_flag": active_flag, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - content_type=content_type, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws( + vws_client=vws_client, + data=data, + content_type=content_type, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_not_set( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - The active flag defaults to True if it is not set. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """The active flag defaults to True if it is not set.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'my_example_name', - 'width': 1234, - 'image': image_data_encoded, + "name": "my_example_name", + "width": 1234, + "image": image_data_encoded, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) - - target_id = response.json()['target_id'] + response = _add_target_to_vws(vws_client=vws_client, data=data) + response_json = json.loads(s=response.text) + target_id = response_json["target_id"] target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.active_flag is True + @staticmethod def test_set_to_none( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - The active flag defaults to True if it is set to NULL. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """The active flag defaults to True if it is set to NULL.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'my_example_name', - 'width': 1234, - 'image': image_data_encoded, - 'active_flag': None, + "name": "my_example_name", + "width": 1234, + "image": image_data_encoded, + "active_flag": None, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + response = _add_target_to_vws(vws_client=vws_client, data=data) - target_id = response.json()['target_id'] + response_json = json.loads(s=response.text) + target_id = response_json["target_id"] target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.active_flag is True -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUnexpectedData: """ - Tests for passing data which is not mandatory or allowed to the endpoint. + Tests for passing data which is not mandatory or allowed to the + endpoint. """ + @staticmethod def test_invalid_extra_data( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - A `BAD_REQUEST` response is returned when unexpected data is given. + A `BAD_REQUEST` response is returned when unexpected data is + given. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'extra_thing': 1, + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "extra_thing": 1, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestApplicationMetadata: - """ - Tests for the application metadata parameter. - """ - - _MAX_METADATA_BYTES = 1024 * 1024 - 1 + """Tests for the application metadata parameter.""" + @staticmethod @pytest.mark.parametrize( - 'metadata', - [ - b'a', - b'a' * _MAX_METADATA_BYTES, + argnames="metadata", + argvalues=[ + b"a", + b"a" * _MAX_METADATA_BYTES, ], - ids=['Short', 'Max length'], + ids=["Short", "Max length"], ) def test_base64_encoded( - self, - vuforia_database: VuforiaDatabase, + *, image_file_failed_state: io.BytesIO, metadata: bytes, + vws_client: VWS, ) -> None: - """ - A base64 encoded string is valid application metadata. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - metadata_encoded = base64.b64encode(metadata).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': metadata_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + """A base64 encoded string is valid application metadata.""" + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" ) - assert_success(response=response) + vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=metadata_encoded, + active_flag=True, + ) + @staticmethod def test_null( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - NULL is valid application metadata. - """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + """NULL is valid application metadata.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': None, + request_data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "application_metadata": None, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + response = _add_target_to_vws( + vws_client=vws_client, + data=request_data, ) assert_success(response=response) + @staticmethod def test_invalid_type( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ Values which are not a string or NULL are not valid application metadata. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': 1, + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "application_metadata": 1, } - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, high_quality_image: io.BytesIO, not_base64_encoded_processable: str, + vws_client: VWS, ) -> None: """ - Some strings which are not valid base64 encoded strings are allowed as + Some strings which are not valid base64 encoded strings are + allowed as application metadata. """ - image_content = high_quality_image.getvalue() - image_data_encoded = base64.b64encode(image_content).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': not_base64_encoded_processable, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=not_base64_encoded_processable, + active_flag=True, ) - assert_success(response=response) - + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, high_quality_image: io.BytesIO, not_base64_encoded_not_processable: str, + vws_client: VWS, ) -> None: """ - Some strings which are not valid base64 encoded strings are not allowed + Some strings which are not valid base64 encoded strings are not + allowed as application metadata. """ - image_content = high_quality_image.getvalue() - image_data_encoded = base64.b64encode(image_content).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': not_base64_encoded_not_processable, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, - ) + with pytest.raises(expected_exception=FailError) as exc: + vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=not_base64_encoded_not_processable, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) + @staticmethod def test_metadata_too_large( - self, - vuforia_database: VuforiaDatabase, + *, image_file_failed_state: io.BytesIO, + vws_client: VWS, ) -> None: """ - A base64 encoded string of greater than 1024 * 1024 bytes is too large + A base64 encoded string of greater than 1024 * 1024 bytes is too + large for application metadata. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - metadata = b'a' * (self._MAX_METADATA_BYTES + 1) - metadata_encoded = base64.b64encode(metadata).decode('ascii') - - data = { - 'name': 'example_name', - 'width': 1, - 'image': image_data_encoded, - 'application_metadata': metadata_encoded, - } - - response = add_target_to_vws( - vuforia_database=vuforia_database, - data=data, + metadata = b"a" * (_MAX_METADATA_BYTES + 1) + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" ) + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: + vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=metadata_encoded, + active_flag=True, + ) + assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.METADATA_TOO_LARGE, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" + @staticmethod def test_inactive_project( - self, - inactive_database: VuforiaDatabase, + *, image_file_failed_state: io.BytesIO, + inactive_vws_client: VWS, ) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - - data = { - 'name': 'example', - 'width': 1, - 'image': image_data_encoded, - } - - response = add_target_to_vws( - vuforia_database=inactive_database, - data=data, - content_type='application/json', - ) + with pytest.raises(expected_exception=ProjectInactiveError) as exc: + inactive_vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.PROJECT_INACTIVE, ) diff --git a/tests/mock_vws/test_authorization_header.py b/tests/mock_vws/test_authorization_header.py index 27976ae87..3c18e914c 100644 --- a/tests/mock_vws/test_authorization_header.py +++ b/tests/mock_vws/test_authorization_header.py @@ -1,70 +1,76 @@ -""" -Tests for the `Authorization` header. -""" +"""Tests for the `Authorization` header.""" import io +import json import uuid from http import HTTPStatus -from pathlib import Path -from typing import Dict from urllib.parse import urlparse import pytest -import requests -from requests.structures import CaseInsensitiveDict from vws import VWS, CloudRecoService from vws.exceptions import cloud_reco_exceptions -from vws.exceptions.vws_exceptions import AuthenticationFailure, Fail +from vws.exceptions.vws_exceptions import AuthenticationFailureError, FailError from vws_auth_tools import rfc_1123_date from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( - assert_valid_date_header, assert_valid_transaction_id, assert_vwq_failure, assert_vws_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestAuthorizationHeader: """ - Tests for what happens when the `Authorization` header is not as expected. + Tests for what happens when the `Authorization` header is not as + expected. """ - def test_missing(self, endpoint: Endpoint) -> None: + @staticmethod + def test_missing(endpoint: Endpoint) -> None: """ - An `UNAUTHORIZED` response is returned when no `Authorization` header + An `UNAUTHORIZED` response is returned when no `Authorization` + header is given. """ date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - - headers: Dict[str, str] = { - **endpoint_headers, - 'Date': date, + new_headers = { + **endpoint.headers, + "Date": date, } + new_headers.pop("Authorization", None) + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) - headers.pop('Authorization', None) + response = new_endpoint.send() - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='text/plain;charset=iso-8859-1', + content_type="text/plain;charset=iso-8859-1", cache_control=None, - www_authenticate='VWS', - connection='keep-alive', + www_authenticate="KWS", + connection="keep-alive", ) - assert response.text == 'Authorization header missing.' + assert response.text == "Authorization header missing." return assert_vws_failure( @@ -74,50 +80,57 @@ def test_missing(self, endpoint: Endpoint) -> None: ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMalformed: - """ - Tests for passing a malformed ``Authorization`` header. - """ + """Tests for passing a malformed ``Authorization`` header.""" - @pytest.mark.parametrize( - 'authorization_string', - ['gibberish', 'VWS'], - ) - def test_one_part_no_space( - self, - endpoint: Endpoint, - authorization_string: str, - ) -> None: - """ - A valid authorization string is two "parts" when split on a space. When + @staticmethod + def test_one_part_no_space(endpoint: Endpoint) -> None: + """A valid authorization string is two "parts" when split on a + space. + + When a string is given which is one "part", a ``BAD_REQUEST`` or ``UNAUTHORIZED`` response is returned. """ date = rfc_1123_date() - headers: Dict[str, str] = { - **endpoint.prepared_request.headers, - 'Authorization': authorization_string, - 'Date': date, + # We use "VWS" as this is the first part of a valid authorization + # string, but really any string which is not two parts when split on a + # space will do. + authorization_string = "VWS" + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='text/plain;charset=iso-8859-1', + content_type="text/plain;charset=iso-8859-1", cache_control=None, - www_authenticate='VWS', - connection='keep-alive', + www_authenticate="KWS", + connection="keep-alive", ) - assert response.text == 'Malformed authorization header.' + assert response.text == "Malformed authorization header." return assert_vws_failure( @@ -126,37 +139,49 @@ def test_one_part_no_space( result_code=ResultCodes.FAIL, ) - def test_one_part_with_space(self, endpoint: Endpoint) -> None: - """ - A valid authorization string is two "parts" when split on a space. When + @staticmethod + def test_one_part_with_space(endpoint: Endpoint) -> None: + """A valid authorization string is two "parts" when split on a + space. + + When a string is given which is one "part", a ``BAD_REQUEST`` or ``UNAUTHORIZED`` response is returned. """ - authorization_string = 'VWS ' + authorization_string = "VWS " date = rfc_1123_date() - - headers: Dict[str, str] = { - **endpoint.prepared_request.headers, - 'Authorization': authorization_string, - 'Date': date, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='text/plain;charset=iso-8859-1', + content_type="text/plain;charset=iso-8859-1", cache_control=None, - www_authenticate='VWS', - connection='keep-alive', + www_authenticate="KWS", + connection="keep-alive", ) - assert response.text == 'Malformed authorization header.' + assert response.text == "Malformed authorization header." return assert_vws_failure( @@ -165,52 +190,47 @@ def test_one_part_with_space(self, endpoint: Endpoint) -> None: result_code=ResultCodes.FAIL, ) - @pytest.mark.parametrize( - 'authorization_string', - [ - 'VWS foobar:', - 'VWS foobar', - ], - ) - def test_missing_signature( - self, - endpoint: Endpoint, - authorization_string: str, - ) -> None: + @staticmethod + def test_missing_signature(endpoint: Endpoint) -> None: """ If a signature is missing `Authorization` header is given, a ``BAD_REQUEST`` response is given. """ date = rfc_1123_date() - headers: Dict[str, str] = { - **endpoint.prepared_request.headers, - 'Authorization': authorization_string, - 'Date': date, + authorization_string = "VWS foobar:" + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - content_type='text/html;charset=iso-8859-1', - cache_control='must-revalidate,no-cache,no-store', - www_authenticate=None, - connection='keep-alive', + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", ) - content_filename = 'jetty_error_array_out_of_bounds.html' - content_filename_2 = 'jetty_error_array_out_of_bounds_2.html' - content_path = Path(__file__).parent / content_filename - content_path_2 = Path(__file__).parent / content_filename_2 - content_text = content_path.read_text() - content_2_text = content_path_2.read_text() - assert response.text in (content_text, content_2_text) + assert response.text == "Malformed authorization header." return assert_vws_failure( @@ -220,33 +240,33 @@ def test_missing_signature( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestBadKey: - """ - Tests for making requests with incorrect keys. - """ + """Tests for making requests with incorrect keys.""" + @staticmethod def test_bad_access_key_services( - self, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - If the server access key given does not match any database, a ``Fail`` + If the server access key given does not match any database, a + ``Fail`` response is returned. """ vws_client = VWS( - server_access_key='example', + server_access_key="example", server_secret_key=vuforia_database.server_secret_key, ) - with pytest.raises(Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.get_target_record(target_id=uuid.uuid4().hex) assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + @staticmethod def test_bad_access_key_query( - self, - vuforia_database: VuforiaDatabase, + *, + vuforia_database: CloudDatabase, high_quality_image: io.BytesIO, ) -> None: """ @@ -254,11 +274,13 @@ def test_bad_access_key_query( response is returned. """ cloud_reco_client = CloudRecoService( - client_access_key='example', + client_access_key="example", client_secret_key=vuforia_database.client_secret_key, ) - with pytest.raises(cloud_reco_exceptions.AuthenticationFailure) as exc: + with pytest.raises( + expected_exception=cloud_reco_exceptions.AuthenticationFailureError + ) as exc: cloud_reco_client.query(image=high_quality_image) response = exc.value.response @@ -266,46 +288,49 @@ def test_bad_access_key_query( assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='application/json', + content_type="application/json", cache_control=None, - www_authenticate='VWS', - connection='keep-alive', + www_authenticate="VWS", + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] + result_code = json.loads(s=response.text)["result_code"] + transaction_id = json.loads(s=response.text)["transaction_id"] assert result_code == ResultCodes.AUTHENTICATION_FAILURE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id":' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" ) assert response.text == expected_text + @staticmethod def test_bad_secret_key_services( - self, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ If the server secret key given is incorrect, an - ``AuthenticationFailure`` response is returned. + ``AuthenticationFailureError`` response is returned. """ vws_client = VWS( server_access_key=vuforia_database.server_access_key, - server_secret_key='example', + server_secret_key="example", ) - with pytest.raises(AuthenticationFailure): + with pytest.raises(expected_exception=AuthenticationFailureError): vws_client.get_target_record(target_id=uuid.uuid4().hex) + @staticmethod def test_bad_secret_key_query( - self, - vuforia_database: VuforiaDatabase, + *, + vuforia_database: CloudDatabase, high_quality_image: io.BytesIO, ) -> None: """ @@ -314,10 +339,12 @@ def test_bad_secret_key_query( """ cloud_reco_client = CloudRecoService( client_access_key=vuforia_database.client_access_key, - client_secret_key='example', + client_secret_key="example", ) - with pytest.raises(cloud_reco_exceptions.AuthenticationFailure) as exc: + with pytest.raises( + expected_exception=cloud_reco_exceptions.AuthenticationFailureError + ) as exc: cloud_reco_client.query(image=high_quality_image) response = exc.value.response @@ -325,23 +352,25 @@ def test_bad_secret_key_query( assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='application/json', + content_type="application/json", cache_control=None, - www_authenticate='VWS', - connection='keep-alive', + www_authenticate="VWS", + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] + result_code = json.loads(s=response.text)["result_code"] + transaction_id = json.loads(s=response.text)["transaction_id"] assert result_code == ResultCodes.AUTHENTICATION_FAILURE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id":' f'"{transaction_id}",' f'"result_code":"{result_code}"' - '}' + "}" ) assert response.text == expected_text diff --git a/tests/mock_vws/test_content_length.py b/tests/mock_vws/test_content_length.py index 7a5ac12e7..41e34fe24 100644 --- a/tests/mock_vws/test_content_length.py +++ b/tests/mock_vws/test_content_length.py @@ -1,103 +1,192 @@ -""" -Tests for the ``Content-Length`` header. -""" +"""Tests for the ``Content-Length`` header.""" +import textwrap from http import HTTPStatus from urllib.parse import urlparse import pytest -import requests -from requests.structures import CaseInsensitiveDict from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( + assert_valid_date_header, assert_vwq_failure, assert_vws_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestIncorrect: - """ - Tests for the ``Content-Length`` header set incorrectly. + """Tests for the ``Content-Length`` header set incorrectly. We cannot test what happens if ``Content-Length`` is removed from a prepared request because ``requests-mock`` behaves differently to ``requests`` - https://github.com/jamielennox/requests-mock/issues/80. """ - def test_not_integer(self, endpoint: Endpoint) -> None: + @staticmethod + def test_not_integer(endpoint: Endpoint) -> None: """ - A ``BAD_REQUEST`` error is given when the given ``Content-Length`` is + A ``BAD_REQUEST`` error is given when the given ``Content- + Length`` is not an integer. """ - endpoint_headers = dict(endpoint.prepared_request.headers) - if not endpoint_headers.get('Content-Type'): + if not endpoint.headers.get("Content-Type"): return - content_length = '0.4' - headers = {**endpoint_headers, 'Content-Length': content_length} - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + content_length = "0.4" - assert response.text == '' - assert dict(response.headers) == { - 'Content-Length': '0', - 'Connection': 'Close', + new_headers = { + **endpoint.headers, + "Content-Length": content_length, } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) assert response.status_code == HTTPStatus.BAD_REQUEST - def test_too_large(self, endpoint: Endpoint) -> None: - """ - A ``GATEWAY_TIMEOUT`` is given if the given content length is too - large. - """ - endpoint_headers = dict(endpoint.prepared_request.headers) - if not endpoint_headers.get('Content-Type'): + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + assert not response.text + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "Close", + } return - content_length = str(int(endpoint_headers['Content-Length']) + 1) - headers = {**endpoint_headers, 'Content-Length': content_length} + assert_valid_date_header(response=response) + expected_response_text = textwrap.dedent( + text="""\ + \r + 400 Bad Request\r + \r +

400 Bad Request

\r + \r + \r + """, + ) + assert response.text == expected_response_text + expected_headers = { + "Content-Length": str(object=len(response.text)), + "Content-Type": "text/html", + "Connection": "close", + "Server": "awselb/2.0", + "Date": response.headers["Date"], + } + assert response.headers == expected_headers + + @staticmethod + @pytest.mark.skip(reason="It takes too long to run this test.") + def test_too_large(endpoint: Endpoint) -> None: # pragma: no cover + """An error is given if the given content length is too large.""" + if not endpoint.headers.get("Content-Type"): + pytest.skip(reason="No Content-Type header for this request") + + netloc = urlparse(url=endpoint.base_url).netloc + content_length = str( + object=int(endpoint.headers["Content-Length"]) + 1 + ) - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_headers = { + **endpoint.headers, + "Content-Length": content_length, + } - assert response.text == '' - assert dict(response.headers) == { - 'Content-Length': '0', - 'Connection': 'keep-alive', + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + # We do not use ``handle_server_errors`` here because we do not want to + # retry on the Gateway Timeout. + if netloc == "cloudreco.vuforia.com": + assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT + assert not response.text + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "keep-alive", + } + return + + handle_server_errors(response=response) + assert_valid_date_header(response=response) + # We have seen both of these response texts. + assert response.text in {"stream timeout", ""} + expected_headers = { + "Content-Length": str(object=len(response.text)), + "Connection": "close", + "Content-Type": "text/plain", + "server": "envoy", + "Date": response.headers["Date"], } - assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT + assert response.headers == expected_headers + assert response.status_code == HTTPStatus.REQUEST_TIMEOUT - def test_too_small(self, endpoint: Endpoint) -> None: + @staticmethod + def test_too_small(endpoint: Endpoint) -> None: """ - An ``UNAUTHORIZED`` response is given if the given content length is + An ``UNAUTHORIZED`` response is given if the given content + length is too small. """ - endpoint_headers = dict(endpoint.prepared_request.headers) - if not endpoint_headers.get('Content-Type'): + if not endpoint.headers.get("Content-Type"): return - content_length = str(int(endpoint_headers['Content-Length']) - 1) - headers = {**endpoint_headers, 'Content-Length': content_length} + real_content_length = len(endpoint.data) + content_length = real_content_length - 1 + + new_headers = { + **endpoint.headers, + "Content-Length": str(object=content_length), + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='application/json', + content_type="application/json", cache_control=None, - www_authenticate='VWS', - connection='keep-alive', + www_authenticate="VWS", + connection="keep-alive", ) return diff --git a/tests/mock_vws/test_database_summary.py b/tests/mock_vws/test_database_summary.py index f9e2542e5..86448394b 100644 --- a/tests/mock_vws/test_database_summary.py +++ b/tests/mock_vws/test_database_summary.py @@ -1,41 +1,57 @@ -""" -Tests for the mock of the database summary endpoint. -""" +"""Tests for the mock of the database summary endpoint.""" import io import logging -import time import uuid from http import HTTPStatus import pytest +from beartype import beartype +from tenacity import RetryCallState, retry +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed from vws import VWS, CloudRecoService -from vws.exceptions.vws_exceptions import Fail +from vws.exceptions.vws_exceptions import FailError from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase -LOGGER = logging.getLogger(__name__) -LOGGER.setLevel(logging.DEBUG) +LOGGER = logging.getLogger(name=__name__) +LOGGER.setLevel(level=logging.DEBUG) +@beartype +def _log_attempt_number(retry_state: RetryCallState) -> None: + """Log the attempt number of a retry.""" + attempt_number: int = retry_state.attempt_number + message = f"Attempt number: {attempt_number}" + LOGGER.debug(msg=message) + + +@retry( + # We wait 0.2 seconds rather than less than that to decrease the number + # of calls made to the API, to decrease the likelihood of hitting the + # request quota. + wait=wait_fixed(wait=0.2), + # Wait up to 700 seconds (arbitrary, though we saw timeouts with 500 + # seconds) for the number of images in various categories to match the + # expected number. This is necessary because the database summary endpoint + # lags behind the real data. + stop=stop_after_delay(max_delay=700), + retry=retry_if_exception_type(exception_types=(AssertionError,)), + before=_log_attempt_number, +) def _wait_for_image_numbers( + *, vws_client: VWS, active_images: int, inactive_images: int, failed_images: int, processing_images: int, ) -> None: - """ - Wait up to 500 seconds (arbitrary, though we saw timeouts with 300 seconds) - for the number of images in various categories to match the expected - number. - - This is necessary because the database summary endpoint lags behind the - real data. - - This is susceptible to false positives because if, for example, we expect - no images, and the endpoint adds images with a delay, we will not know. + """Wait for the number of images in various categories of the database + summary to match the expected given numbers. Args: vws_client: The client to use to connect to Vuforia. @@ -45,66 +61,44 @@ def _wait_for_image_numbers( processing_images: The expected number of processing images. Raises: - Exception: The numbers of images in various categories do not match + ValueError: The numbers of images in various categories do not match within the time limit. """ - requirements = { - 'active_images': active_images, - 'inactive_images': inactive_images, - 'failed_images': failed_images, - 'processing_images': processing_images, - } - - maximum_wait_seconds = 500 - start_time = time.monotonic() + database_summary_report = vws_client.get_database_summary_report() - # If we wait for all requirements to match at the same time, - # we will often not reach that. - # We therefore wait for each requirement to match at least once. - - # We wait 0.2 seconds rather than less than that to decrease the number - # of calls made to the API, to decrease the likelihood of hitting the - # request quota. - sleep_seconds = 0.2 - - for key, value in requirements.items(): - while True: - seconds_waited = time.monotonic() - start_time - if seconds_waited > maximum_wait_seconds: # pragma: no cover - raise Exception('Timed out waiting.') - - report = vws_client.get_database_summary_report() - relevant_images_in_summary = getattr(report, key) - if value != relevant_images_in_summary: # pragma: no cover - message = ( - f'Expected {value} `{key}`s. ' - f'Found {relevant_images_in_summary} `{key}`s.' - ) - LOGGER.debug(message) + expected = { + "active_images": active_images, + "inactive_images": inactive_images, + "failed_images": failed_images, + "processing_images": processing_images, + } - time.sleep(sleep_seconds) + actual = { + "active_images": database_summary_report.active_images, + "inactive_images": database_summary_report.inactive_images, + "failed_images": database_summary_report.failed_images, + "processing_images": database_summary_report.processing_images, + } - # This makes the entire test invalid. - # However, we have found that without this Vuforia is flaky. - # We have waited over 10 minutes for the summary to change and - # that is not sustainable in a test suite. - break + msg = f"Expected: {expected}. Actual: {actual}" + LOGGER.debug(msg=msg) + assert actual == expected -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDatabaseSummary: """ - Tests for the mock of the database summary endpoint at `GET /summary`. + Tests for the mock of the database summary endpoint at `GET + /summary`. """ + @staticmethod def test_success( - self, - vuforia_database: VuforiaDatabase, + *, + vuforia_database: CloudDatabase, vws_client: VWS, ) -> None: - """ - It is possible to get a success response. - """ + """It is possible to get a success response.""" report = vws_client.get_database_summary_report() assert report.name == vuforia_database.database_name @@ -116,14 +110,9 @@ def test_success( processing_images=0, ) - def test_active_images( - self, - vws_client: VWS, - target_id: str, - ) -> None: - """ - The number of images in the active state is returned. - """ + @staticmethod + def test_active_images(*, vws_client: VWS, target_id: str) -> None: + """The number of images in the active state is returned.""" vws_client.wait_for_target_processed(target_id=target_id) _wait_for_image_numbers( @@ -134,14 +123,13 @@ def test_active_images( processing_images=0, ) + @staticmethod def test_failed_images( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - The number of images with a 'failed' status is returned. - """ + """The number of images with a 'failed' status is returned.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -160,13 +148,15 @@ def test_failed_images( processing_images=0, ) + @staticmethod def test_inactive_images( - self, + *, vws_client: VWS, image_file_success_state_low_rating: io.BytesIO, ) -> None: """ - The number of images with a False active_flag and a 'success' status is + The number of images with a False active_flag and a 'success' + status is returned. """ target_id = vws_client.add_target( @@ -187,14 +177,13 @@ def test_inactive_images( processing_images=0, ) + @staticmethod def test_inactive_failed( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - An image with a 'failed' status does not show as inactive. - """ + """An image with a 'failed' status does not show as inactive.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -213,14 +202,13 @@ def test_inactive_failed( processing_images=0, ) + @staticmethod def test_deleted( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - Deleted targets are not shown in the summary. - """ + """Deleted targets are not shown in the summary.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -242,31 +230,28 @@ def test_deleted( class TestProcessingImages: - """ - Tests for processing images. - - These tests are run only on the mock, and not the real implementation. + """Tests for processing images. - This is because the real implementation is not reliable. - This is a documented difference between the mock and the real + These tests are run only on the mock, and not the real implementation. + + This is because the real implementation is not reliable. This is a + documented difference between the mock and the real implementation. """ + @staticmethod def test_processing_images( - self, image_file_success_state_low_rating: io.BytesIO, ) -> None: - """ - The number of images in the processing state is returned. - """ - database = VuforiaDatabase() + """The number of images in the processing state is returned.""" + database = CloudDatabase() vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -284,41 +269,42 @@ def test_processing_images( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestQuotas: - """ - Tests for quotas and thresholds. - """ + """Tests for quotas and thresholds.""" + + @staticmethod + def test_quotas(vws_client: VWS) -> None: + """Quotas are included in the database summary. - def test_quotas(self, vws_client: VWS) -> None: - """ - Quotas are included in the database summary. These match the quotas given for a free license. """ report = vws_client.get_database_summary_report() - assert report.target_quota == 1000 - assert report.request_quota == 100000 - assert report.reco_threshold == 1000 + expected_target_quota = 1000 + expected_request_quota = 100000 + expected_reco_threshold = 1000 + assert report.target_quota == expected_target_quota + assert report.request_quota == expected_request_quota + assert report.reco_threshold == expected_reco_threshold -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestRecos: - """ - Tests for the recognition count fields. - """ + """Tests for the recognition count fields.""" + @staticmethod def test_query_request( - self, + *, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: - """ - The ``*_recos`` counts seem to be delayed by a significant amount of + """The ``*_recos`` counts seem to be delayed by a significant + amount of time. - We therefore test that they exist, are integers and do not change - between quick requests. + We therefore test that they exist, are integers and do not + change between quick requests. """ target_id = vws_client.add_target( name=uuid.uuid4().hex, @@ -344,18 +330,15 @@ def test_query_request( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestRequestUsage: - """ - Tests for the ``request_usage`` field. - """ + """Tests for the ``request_usage`` field.""" - def test_target_request( - self, - vws_client: VWS, - ) -> None: + @staticmethod + def test_target_request(vws_client: VWS) -> None: """ - The ``request_usage`` count does not increase with each request to the + The ``request_usage`` count does not increase with each request + to the target API. """ report = vws_client.get_database_summary_report() @@ -365,21 +348,23 @@ def test_target_request( new_request_usage = report.request_usage assert new_request_usage == original_request_usage + @staticmethod def test_bad_target_request( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: """ - The ``request_usage`` count does not increase with each request to the + The ``request_usage`` count does not increase with each request + to the target API, even if it is a bad request. """ report = vws_client.get_database_summary_report() original_request_usage = report.request_usage - with pytest.raises(Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.add_target( - name='example', + name="example", width=-1, image=high_quality_image, active_flag=True, @@ -392,14 +377,16 @@ def test_bad_target_request( new_request_usage = report.request_usage assert new_request_usage == original_request_usage + @staticmethod def test_query_request( - self, + *, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: """ - The ``request_usage`` count does not increase with each query. + The ``request_usage`` count does not increase with each + query. """ report = vws_client.get_database_summary_report() original_request_usage = report.request_usage @@ -411,17 +398,13 @@ def test_query_request( assert new_request_usage == original_request_usage -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" + @staticmethod def test_inactive_project( - self, inactive_vws_client: VWS, ) -> None: - """ - The project's active state does not affect the database summary. - """ + """The project's active state does not affect the database summary.""" inactive_vws_client.get_database_summary_report() diff --git a/tests/mock_vws/test_date_header.py b/tests/mock_vws/test_date_header.py index 555baed62..d5e5f0b45 100644 --- a/tests/mock_vws/test_date_header.py +++ b/tests/mock_vws/test_date_header.py @@ -1,81 +1,85 @@ -""" -Tests for the `Date` header. -""" +"""Tests for the `Date` header.""" +import json from datetime import datetime, timedelta from http import HTTPStatus -from typing import Dict from urllib.parse import urlparse from zoneinfo import ZoneInfo import pytest -import requests from freezegun import freeze_time -from requests.structures import CaseInsensitiveDict from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( assert_query_success, + assert_valid_transaction_id, assert_vwq_failure, assert_vws_failure, assert_vws_response, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors _VWS_MAX_TIME_SKEW = timedelta(minutes=5) _VWQ_MAX_TIME_SKEW = timedelta(minutes=65) _LEEWAY = timedelta(seconds=10) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMissing: - """ - Tests for what happens when the `Date` header is missing. - """ + """Tests for what happens when the `Date` header is missing.""" - def test_no_date_header( - self, - endpoint: Endpoint, - ) -> None: + @staticmethod + def test_no_date_header(endpoint: Endpoint) -> None: """ - A `BAD_REQUEST` response is returned when no `Date` header is given. + A `BAD_REQUEST` response is returned when no `Date` header is + given. """ - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) - authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, - date='', - request_path=endpoint.prepared_request.path_url, + date="", + request_path=endpoint.path_url, ) - headers: Dict[str, str] = { - **endpoint_headers, - 'Authorization': authorization_string, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, } - headers.pop('Date', None) - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) - - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': - expected_content_type = 'text/plain;charset=iso-8859-1' - assert response.text == 'Date header required.' + new_headers.pop("Date", None) + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + + if netloc == "cloudreco.vuforia.com": + expected_content_type = "text/plain;charset=iso-8859-1" + assert response.text == "Date header required." assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, content_type=expected_content_type, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) return @@ -86,63 +90,67 @@ def test_no_date_header( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestFormat: """ - Tests for what happens when the `Date` header is not in the - expected format. + Tests for what happens when the `Date` header is not in the expected + format. """ - def test_incorrect_date_format( - self, - endpoint: Endpoint, - ) -> None: - """ - A `BAD_REQUEST` response is returned when the date given in the date + @staticmethod + def test_incorrect_date_format(endpoint: Endpoint) -> None: + """A `BAD_REQUEST` response is returned when the date given in the + date header is not in the expected format (RFC 1123) to VWS API. An `UNAUTHORIZED` response is returned to the VWQ API. """ - gmt = ZoneInfo('GMT') - with freeze_time(datetime.now(tz=gmt)): - now = datetime.now() - date_incorrect_format = now.strftime('%a %b %d %H:%M:%S') - - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) + gmt = ZoneInfo(key="GMT") + with freeze_time(time_to_freeze=datetime.now(tz=gmt)): + now = datetime.now(tz=gmt) + date_incorrect_format = now.strftime(format="%a %b %d %H:%M:%S") authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, date=date_incorrect_format, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date_incorrect_format, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date_incorrect_format, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + response = new_endpoint.send() + + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': - assert response.text == 'Malformed date header.' + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + assert response.text == "Malformed date header." assert_vwq_failure( response=response, status_code=HTTPStatus.UNAUTHORIZED, - content_type='text/plain;charset=iso-8859-1', + content_type="text/plain;charset=iso-8859-1", cache_control=None, - www_authenticate='VWS', - connection='keep-alive', + www_authenticate="KWS", + connection="keep-alive", ) return @@ -153,133 +161,302 @@ def test_incorrect_date_format( ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestSkewedTime: """ Tests for what happens when the `Date` header is given with an - unexpected time. + unexpected + time. """ - @pytest.mark.parametrize( - 'time_multiplier', - [1, -1], - ids=(['After', 'Before']), - ) - def test_date_out_of_range( - self, - time_multiplier: int, - endpoint: Endpoint, - ) -> None: - """ - If the date header is more than five minutes (target API) or 65 minutes - (query API) before or after the request is sent, a `FORBIDDEN` response + @staticmethod + def test_date_out_of_range_after(endpoint: Endpoint) -> None: + """If the date header is more than five minutes (target API) or 65 + minutes (query API) after the request is sent, a `FORBIDDEN` + response is returned. - Because there is a small delay in sending requests and Vuforia isn't - consistent, some leeway is given. + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { - 'vws.vuforia.com': _VWS_MAX_TIME_SKEW, - 'cloudreco.vuforia.com': _VWQ_MAX_TIME_SKEW, + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, }[netloc] time_difference_from_now = skew + _LEEWAY - time_difference_from_now *= time_multiplier - gmt = ZoneInfo('GMT') - with freeze_time(datetime.now(tz=gmt) + time_difference_from_now): + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now + ): date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) - authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) # Even with the query endpoint, we get a JSON response. + if netloc == "cloudreco.vuforia.com": + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + assert_vws_failure( response=response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.REQUEST_TIME_TOO_SKEWED, ) - @pytest.mark.parametrize( - 'time_multiplier', - [1, -1], - ids=(['After', 'Before']), - ) - def test_date_in_range( - self, - time_multiplier: int, - endpoint: Endpoint, - ) -> None: + @staticmethod + def test_date_out_of_range_before(endpoint: Endpoint) -> None: + """If the date header is more than five minutes (target API) or 65 + minutes (query API) before the request is sent, a `FORBIDDEN` + response + is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - If a date header is within five minutes before or after the request - is sent, no error is returned. + netloc = urlparse(url=endpoint.base_url).netloc + skew = { + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, + }[netloc] + time_difference_from_now = skew + _LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now + ): + date = rfc_1123_date() - Because there is a small delay in sending requests and Vuforia isn't - consistent, some leeway is given. + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + # Even with the query endpoint, we get a JSON response. + if netloc == "cloudreco.vuforia.com": + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + result_code=ResultCodes.REQUEST_TIME_TOO_SKEWED, + ) + + @staticmethod + def test_date_in_range_after(endpoint: Endpoint) -> None: + """If a date header is within five minutes after the request is + sent, + no error is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { - 'vws.vuforia.com': _VWS_MAX_TIME_SKEW, - 'cloudreco.vuforia.com': _VWQ_MAX_TIME_SKEW, + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, }[netloc] time_difference_from_now = skew - _LEEWAY - time_difference_from_now *= time_multiplier - gmt = ZoneInfo('GMT') - with freeze_time(datetime.now(tz=gmt) + time_difference_from_now): + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now + ): date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - content = endpoint.prepared_request.body or b'' - assert isinstance(content, bytes) + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + assert_query_success(response=response) + return + + if endpoint.successful_headers_result_code is None: + assert ( + response.status_code == endpoint.successful_headers_status_code + ) + return + + assert_vws_response( + response=response, + status_code=endpoint.successful_headers_status_code, + result_code=endpoint.successful_headers_result_code, + ) + + @staticmethod + def test_date_in_range_before(endpoint: Endpoint) -> None: + """If a date header is within five minutes before the request is + sent, + no error is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. + """ + netloc = urlparse(url=endpoint.base_url).netloc + skew = { + "vws.vuforia.com": _VWS_MAX_TIME_SKEW, + "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, + }[netloc] + time_difference_from_now = skew - _LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now + ): + date = rfc_1123_date() authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), - content=content, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, } - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_query_success(response=response) return + if endpoint.successful_headers_result_code is None: + assert ( + response.status_code == endpoint.successful_headers_status_code + ) + return + assert_vws_response( response=response, status_code=endpoint.successful_headers_status_code, diff --git a/tests/mock_vws/test_delete_target.py b/tests/mock_vws/test_delete_target.py index e0fc5f789..26befe849 100644 --- a/tests/mock_vws/test_delete_target.py +++ b/tests/mock_vws/test_delete_target.py @@ -1,34 +1,27 @@ -""" -Tests for deleting targets. -""" +"""Tests for deleting targets.""" from http import HTTPStatus import pytest from vws import VWS from vws.exceptions.vws_exceptions import ( - ProjectInactive, - TargetStatusProcessing, - UnknownTarget, + ProjectInactiveError, + TargetStatusProcessingError, + UnknownTargetError, ) from mock_vws._constants import ResultCodes from tests.mock_vws.utils.assertions import assert_vws_failure -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDelete: - """ - Tests for deleting targets. - """ + """Tests for deleting targets.""" - def test_no_wait( - self, - target_id: str, - vws_client: VWS, - ) -> None: - """ - When attempting to delete a target immediately after creating it, a + @staticmethod + def test_no_wait(*, target_id: str, vws_client: VWS) -> None: + """When attempting to delete a target immediately after creating + it, a `FORBIDDEN` response is returned. This is because the target goes into a processing state. @@ -36,7 +29,9 @@ def test_no_wait( There is a race condition here - if the target goes into a success or fail state before the deletion attempt. """ - with pytest.raises(TargetStatusProcessing) as exc: + with pytest.raises( + expected_exception=TargetStatusProcessingError + ) as exc: vws_client.delete_target(target_id=target_id) assert_vws_failure( @@ -45,36 +40,28 @@ def test_no_wait( result_code=ResultCodes.TARGET_STATUS_PROCESSING, ) - def test_processed( - self, - target_id: str, - vws_client: VWS, - ) -> None: - """ - When a target has finished processing, it can be deleted. - """ + @staticmethod + def test_processed(*, target_id: str, vws_client: VWS) -> None: + """When a target has finished processing, it can be deleted.""" vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - with pytest.raises(UnknownTarget): + with pytest.raises(expected_exception=UnknownTargetError): vws_client.get_target_record(target_id=target_id) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - target_id = 'abc12345a' - with pytest.raises(ProjectInactive) as exc: + target_id = "abc12345a" + with pytest.raises(expected_exception=ProjectInactiveError) as exc: inactive_vws_client.delete_target(target_id=target_id) assert_vws_failure( diff --git a/tests/mock_vws/test_docker.py b/tests/mock_vws/test_docker.py index 1972245a8..02f1fd873 100644 --- a/tests/mock_vws/test_docker.py +++ b/tests/mock_vws/test_docker.py @@ -1,104 +1,163 @@ -""" -Tests for running the mock server in Docker. -""" +"""Tests for running the mock server in Docker.""" import io -import os import uuid +from collections.abc import Iterable, Iterator from http import HTTPStatus -from pathlib import Path -from typing import Iterator +from typing import TYPE_CHECKING import docker import pytest import requests +from beartype import beartype +from docker.errors import BuildError, NotFound +from docker.models.containers import Container from docker.models.networks import Network +from tenacity import retry +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed from vws import VWS, CloudRecoService -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase +if TYPE_CHECKING: + from docker.models.images import Image -@pytest.fixture(name='custom_bridge_network') + +@retry( + wait=wait_fixed(wait=0.5), + stop=stop_after_delay(max_delay=20), + retry=retry_if_exception_type( + exception_types=(requests.exceptions.ConnectionError, ValueError), + ), + reraise=True, +) +@beartype +def wait_for_health_check(container: Container) -> None: + """Wait for a container to pass its health check.""" + container.reload() + health_status = container.attrs["State"]["Health"]["Status"] + # In theory this might not be hit by coverage. + # Let's keep it required by coverage for now. + if health_status != "healthy": + error_message = ( + f"Container {container.name} is not healthy: {health_status}" + ) + raise ValueError(error_message) + + +@pytest.fixture(name="custom_bridge_network") def fixture_custom_bridge_network() -> Iterator[Network]: - """ - Yield a custom bridge network which containers can connect to. + """Yield a custom bridge network which containers can connect to. + + This also cleans up all containers connected to the network and the network + after the test. + + Yields: + A custom bridge network. """ client = docker.from_env() + name = "test-vws-bridge-" + uuid.uuid4().hex try: - network = client.networks.create( - name='test-vws-bridge-' + uuid.uuid4().hex, - driver='bridge', - ) - except docker.errors.NotFound: + network = client.networks.create(name=name, driver="bridge") + # We skip coverage here because combining Windows and Linux coverage + # is challenging. + except NotFound: # pragma: no cover # On Windows the "bridge" network driver is not available and we use # the "nat" driver instead. - network = client.networks.create( - name='test-vws-bridge-' + uuid.uuid4().hex, - driver='nat', - ) + network = client.networks.create(name=name, driver="nat") + try: yield network finally: + network.reload() + images_to_remove: Iterable[Image] = set() + for container in network.containers: + network.disconnect(container=container) + container.stop() + container.remove(v=True, force=True) + assert container.image is not None + images_to_remove = {*images_to_remove, container.image} + + # This does leave behind untagged images. + for image in images_to_remove: + image.remove(force=True) network.remove() -@pytest.mark.skipif( - os.environ.get('SKIP_DOCKER_BUILD_TESTS') == '1', - reason='Docker test skipped because environment variable was set.', -) +@pytest.mark.requires_docker_build def test_build_and_run( + *, high_quality_image: io.BytesIO, custom_bridge_network: Network, + request: pytest.FixtureRequest, ) -> None: """ - It is possible to build Docker images which combine to make a working mock + It is possible to build Docker images which combine to make a + working mock application. """ - repository_root = Path(__file__).parent.parent.parent + repository_root = request.config.rootpath client = docker.from_env() - dockerfile_dir = repository_root / 'src/mock_vws/_flask_server/dockerfiles' - target_manager_dockerfile = ( - dockerfile_dir / 'target_manager' / 'Dockerfile' - ) - vws_dockerfile = dockerfile_dir / 'vws' / 'Dockerfile' - vwq_dockerfile = dockerfile_dir / 'vwq' / 'Dockerfile' + dockerfile = repository_root / "src/mock_vws/_flask_server/Dockerfile" random = uuid.uuid4().hex - target_manager_tag = 'vws-mock-target-manager:latest-' + random - vws_tag = 'vws-mock-vws:latest-' + random - vwq_tag = 'vws-mock-vwq:latest-' + random + target_manager_tag = f"vws-mock-target-manager:latest-{random}" + vws_tag = f"vws-mock-vws:latest-{random}" + vwq_tag = f"vws-mock-vwq:latest-{random}" try: target_manager_image, _ = client.images.build( - path=str(repository_root), - dockerfile=str(target_manager_dockerfile), + path=str(object=repository_root), + dockerfile=str(object=dockerfile), tag=target_manager_tag, + target="target-manager", + rm=True, + ) + # We skip coverage here because combining Windows and Linux coverage + # is challenging. + except BuildError as exc: # pragma: no cover + full_log = "\n".join( + [item["stream"] for item in exc.build_log if "stream" in item], ) - except docker.errors.BuildError as exc: - full_log = '\n'.join( - [item['stream'] for item in exc.build_log if 'stream' in item], + windows_message_substrings = ( + "no matching manifest for windows/amd64", + "no matching manifest for windows(10.0.26100)/amd64", ) # If this assertion fails, it may be useful to look at the other # properties of ``exc``. - assert 'no matching manifest for windows/amd64' in exc.msg, full_log - reason = 'We do not currently support using Windows containers.' - pytest.skip(reason) + if not any( + windows_message_substring in exc.msg + for windows_message_substring in windows_message_substrings + ): + raise AssertionError(full_log) from exc + pytest.skip( + reason="We do not currently support using Windows containers." + ) - vws_image, _ = client.images.build( - path=str(repository_root), - dockerfile=str(vws_dockerfile), - tag=vws_tag, - ) vwq_image, _ = client.images.build( - path=str(repository_root), - dockerfile=str(vwq_dockerfile), + path=str(object=repository_root), + dockerfile=str(object=dockerfile), tag=vwq_tag, + target="vwq", + rm=True, + ) + + vws_image, _ = client.images.build( + path=str(object=repository_root), + dockerfile=str(object=dockerfile), + tag=vws_tag, + target="vws", + rm=True, ) - database = VuforiaDatabase() - target_manager_container_name = 'vws-mock-target-manager-' + random - target_manager_base_url = f'http://{target_manager_container_name}:5000' + database = CloudDatabase() + target_manager_container_name = "vws-mock-target-manager-" + random + target_manager_internal_base_url = ( + f"http://{target_manager_container_name}:5000" + ) target_manager_container = client.containers.run( image=target_manager_image, @@ -110,45 +169,57 @@ def test_build_and_run( vws_container = client.containers.run( image=vws_image, detach=True, - name='vws-mock-vws-' + random, + name="vws-mock-vws-" + random, publish_all_ports=True, network=custom_bridge_network.name, - environment={'TARGET_MANAGER_BASE_URL': target_manager_base_url}, + environment={ + "TARGET_MANAGER_BASE_URL": target_manager_internal_base_url, + }, ) vwq_container = client.containers.run( image=vwq_image, detach=True, - name='vws-mock-vwq-' + random, + name="vws-mock-vwq-" + random, publish_all_ports=True, network=custom_bridge_network.name, - environment={'TARGET_MANAGER_BASE_URL': target_manager_base_url}, + environment={ + "TARGET_MANAGER_BASE_URL": target_manager_internal_base_url, + }, ) - target_manager_container.reload() + for container in (target_manager_container, vws_container, vwq_container): + wait_for_health_check(container=container) + container.reload() + + target_manager_port_attrs = target_manager_container.attrs[ + "NetworkSettings" + ]["Ports"] target_manager_port_attrs = target_manager_container.attrs[ - 'NetworkSettings' - ]['Ports'] - target_manager_host_ip = target_manager_port_attrs['5000/tcp'][0]['HostIp'] - target_manager_host_port = target_manager_port_attrs['5000/tcp'][0][ - 'HostPort' + "NetworkSettings" + ]["Ports"] + target_manager_host_ip = target_manager_port_attrs["5000/tcp"][0]["HostIp"] + target_manager_host_port = target_manager_port_attrs["5000/tcp"][0][ + "HostPort" ] - vws_container.reload() - vws_port_attrs = vws_container.attrs['NetworkSettings']['Ports'] - vws_host_ip = vws_port_attrs['5000/tcp'][0]['HostIp'] - vws_host_port = vws_port_attrs['5000/tcp'][0]['HostPort'] + vws_port_attrs = vws_container.attrs["NetworkSettings"]["Ports"] + vws_host_ip = vws_port_attrs["5000/tcp"][0]["HostIp"] + vws_host_port = vws_port_attrs["5000/tcp"][0]["HostPort"] - vwq_container.reload() - vwq_port_attrs = vwq_container.attrs['NetworkSettings']['Ports'] - vwq_host_ip = vwq_port_attrs['5000/tcp'][0]['HostIp'] - vwq_host_port = vwq_port_attrs['5000/tcp'][0]['HostPort'] + vwq_port_attrs = vwq_container.attrs["NetworkSettings"]["Ports"] + vwq_host_ip = vwq_port_attrs["5000/tcp"][0]["HostIp"] + vwq_host_port = vwq_port_attrs["5000/tcp"][0]["HostPort"] - target_manager_host_url = ( - f'http://{target_manager_host_ip}:{target_manager_host_port}' + base_vws_url = f"http://{vws_host_ip}:{vws_host_port}" + base_vwq_url = f"http://{vwq_host_ip}:{vwq_host_port}" + base_target_manager_url = ( + f"http://{target_manager_host_ip}:{target_manager_host_port}" ) + response = requests.post( - url=f'{target_manager_host_url}/databases', + url=f"{base_target_manager_url}/cloud_databases", json=database.to_dict(), + timeout=30, ) assert response.status_code == HTTPStatus.CREATED @@ -156,11 +227,11 @@ def test_build_and_run( vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, - base_vws_url=f'http://{vws_host_ip}:{vws_host_port}', + base_vws_url=base_vws_url, ) target_id = vws_client.add_target( - name='example', + name="example", width=1, image=high_quality_image, active_flag=True, @@ -172,13 +243,9 @@ def test_build_and_run( cloud_reco_client = CloudRecoService( client_access_key=database.client_access_key, client_secret_key=database.client_secret_key, - base_vwq_url=f'http://{vwq_host_ip}:{vwq_host_port}', + base_vwq_url=base_vwq_url, ) matching_targets = cloud_reco_client.query(image=high_quality_image) - for container in (target_manager_container, vws_container, vwq_container): - container.stop() - container.remove() - assert matching_targets[0].target_id == target_id diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index 31ff24d36..4db00b7e5 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -1,349 +1,849 @@ -""" -Tests for the usage of the mock Flask application. -""" +"""Tests for the usage of the mock Flask application.""" +import email.utils import io +import json +import time import uuid -from http import HTTPStatus +from collections.abc import Iterator +from http import HTTPMethod, HTTPStatus import pytest import requests -from pytest import MonkeyPatch -from requests_mock import Mocker +import responses +from PIL import Image from requests_mock_flask import add_flask_app_to_mock from vws import VWS, CloudRecoService +from vws_auth_tools import authorization_header, rfc_1123_date -from mock_vws._flask_server.target_manager import TARGET_MANAGER_FLASK_APP +from mock_vws._constants import ResultCodes +from mock_vws._flask_server.target_manager import ( + TARGET_MANAGER, + TARGET_MANAGER_FLASK_APP, +) from mock_vws._flask_server.vwq import CLOUDRECO_FLASK_APP from mock_vws._flask_server.vws import VWS_FLASK_APP -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.target import VuMarkTarget from tests.mock_vws.utils.usage_test_helpers import ( - process_deletion_seconds, processing_time_seconds, - recognize_deletion_seconds, ) -_EXAMPLE_URL_FOR_TARGET_MANAGER = 'http://' + uuid.uuid4().hex + '.com' +_EXAMPLE_URL_FOR_TARGET_MANAGER = "http://" + uuid.uuid4().hex + ".com" @pytest.fixture(autouse=True) -def enable_requests_mock( - monkeypatch: MonkeyPatch, - requests_mock: Mocker, -) -> None: - """ - Enable a mock service backed by the Flask applications. - """ - add_flask_app_to_mock( - mock_obj=requests_mock, - flask_app=VWS_FLASK_APP, - base_url='https://vws.vuforia.com', - ) - - add_flask_app_to_mock( - mock_obj=requests_mock, - flask_app=CLOUDRECO_FLASK_APP, - base_url='https://cloudreco.vuforia.com', - ) - - add_flask_app_to_mock( - mock_obj=requests_mock, - flask_app=TARGET_MANAGER_FLASK_APP, - base_url=_EXAMPLE_URL_FOR_TARGET_MANAGER, - ) - - monkeypatch.setenv( - name='TARGET_MANAGER_BASE_URL', - value=_EXAMPLE_URL_FOR_TARGET_MANAGER, - ) +def _(*, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Enable a mock service backed by the Flask applications.""" + with responses.RequestsMock( + assert_all_requests_are_fired=False, + ) as mock_obj: + add_flask_app_to_mock( + mock_obj=mock_obj, + flask_app=VWS_FLASK_APP, + base_url="https://vws.vuforia.com", + ) + + add_flask_app_to_mock( + mock_obj=mock_obj, + flask_app=CLOUDRECO_FLASK_APP, + base_url="https://cloudreco.vuforia.com", + ) + + add_flask_app_to_mock( + mock_obj=mock_obj, + flask_app=TARGET_MANAGER_FLASK_APP, + base_url=_EXAMPLE_URL_FOR_TARGET_MANAGER, + ) + + monkeypatch.setenv( + name="TARGET_MANAGER_BASE_URL", + value=_EXAMPLE_URL_FOR_TARGET_MANAGER, + ) + + yield + + for cloud_database in TARGET_MANAGER.cloud_databases: + TARGET_MANAGER.remove_cloud_database(cloud_database=cloud_database) + for vumark_database in TARGET_MANAGER.vumark_databases: + TARGET_MANAGER.remove_vumark_database(vumark_database=vumark_database) class TestProcessingTime: - """ - Tests for the time taken to process targets in the mock. - """ + """Tests for the time taken to process targets in the mock.""" # There is a race condition in this test type - if tests start to # fail, consider increasing the leeway. - LEEWAY = 0.1 + LEEWAY = 0.5 def test_default( self, image_file_failed_state: io.BytesIO, ) -> None: - """ - By default, targets in the mock take 0.5 seconds to be processed. - """ - database = VuforiaDatabase() - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - requests.post(url=databases_url, json=database.to_dict()) + """By default, targets in the mock takes 2 seconds to be processed.""" + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) time_taken = processing_time_seconds( vuforia_database=database, image=image_file_failed_state, ) - expected = 0.5 - assert abs(expected - time_taken) < self.LEEWAY + expected = 2 + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY def test_custom( self, + *, image_file_failed_state: io.BytesIO, - monkeypatch: MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - It is possible to set a custom processing time. - """ + """It is possible to set a custom processing time.""" + seconds = 5.0 monkeypatch.setenv( - name='PROCESSING_TIME_SECONDS', - value='0.1', + name="PROCESSING_TIME_SECONDS", + value=str(object=seconds), ) - database = VuforiaDatabase() - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - requests.post(url=databases_url, json=database.to_dict()) + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) time_taken = processing_time_seconds( vuforia_database=database, image=image_file_failed_state, ) - expected = 0.1 - assert abs(expected - time_taken) < self.LEEWAY - + expected = seconds + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY -class TestCustomQueryRecognizesDeletionSeconds: - """ - Tests for setting the amount of time after a target has been deleted - until it is not recognized by the query endpoint. - """ - LEEWAY = 0.15 +class TestAddCloudDatabase: + """Tests for adding cloud databases to the mock.""" - def test_default( - self, - high_quality_image: io.BytesIO, - ) -> None: + @staticmethod + def test_duplicate_keys() -> None: """ - By default it takes zero seconds for the Query API on the mock to - recognize that a target has been deleted. - - The real Query API takes between zero and two seconds. - See ``test_query`` for more information. + It is not possible to have multiple cloud databases with + matching + keys. """ - database = VuforiaDatabase() - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - requests.post(url=databases_url, json=database.to_dict()) - time_taken = recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + database = CloudDatabase( + server_access_key="1", + server_secret_key="2", + client_access_key="3", + client_secret_key="4", + database_name="5", ) - expected = 0.2 - assert abs(expected - time_taken) < self.LEEWAY + bad_server_access_key_db = CloudDatabase(server_access_key="1") + bad_server_secret_key_db = CloudDatabase(server_secret_key="2") + bad_client_access_key_db = CloudDatabase(client_access_key="3") + bad_client_secret_key_db = CloudDatabase(client_secret_key="4") + bad_database_name_db = CloudDatabase(database_name="5") - def test_custom( - self, - high_quality_image: io.BytesIO, - monkeypatch: MonkeyPatch, - ) -> None: - """ - It is possible to use set a custom amount of time that it takes for the - Query API on the mock to recognize that a target has been deleted. - """ - # We choose a low time for a quick test. - query_recognizes_deletion = 0.5 - database = VuforiaDatabase() - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - requests.post(url=databases_url, json=database.to_dict()) - monkeypatch.setenv( - name='DELETION_RECOGNITION_SECONDS', - value=str(query_recognizes_deletion), + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' ) - time_taken = recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + client_access_key_conflict_error = ( + "All client access keys must be unique. " + 'There is already a database with the client access key "3".' + ) + client_secret_key_conflict_error = ( + "All client secret keys must be unique. " + 'There is already a database with the client secret key "4".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "5".' ) - expected = query_recognizes_deletion - assert abs(expected - time_taken) < self.LEEWAY - + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) -class TestCustomQueryProcessDeletionSeconds: - """ - Tests for setting the amount of time after a target has been deleted - until it is not processed by the query endpoint. - """ + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_client_access_key_db, client_access_key_conflict_error), + (bad_client_secret_key_db, client_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + response = requests.post( + url=databases_url, + json=bad_database.to_dict(), + timeout=30, + ) - # There is a race condition in this test type - if tests start to - # fail, consider increasing the leeway. - LEEWAY = 0.2 + assert response.status_code == HTTPStatus.CONFLICT + assert response.text == expected_message - def test_default( - self, - high_quality_image: io.BytesIO, - ) -> None: + @staticmethod + def test_give_no_details(high_quality_image: io.BytesIO) -> None: + """It is possible to create a cloud database without giving any + data. """ - By default it takes three seconds for the Query API on the mock to - process that a target has been deleted. + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post(url=databases_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.CREATED - The real Query API takes between seven and thirty seconds. - See ``test_query`` for more information. - """ - database = VuforiaDatabase() - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - requests.post(url=databases_url, json=database.to_dict()) - time_taken = process_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, - ) + data = json.loads(s=response.text) - expected = 3 - assert abs(expected - time_taken) < self.LEEWAY + assert data["targets"] == [] + assert data["state_name"] == "WORKING" + assert "database_name" in data - def test_custom( - self, - high_quality_image: io.BytesIO, - monkeypatch: MonkeyPatch, - ) -> None: - """ - It is possible to use set a custom amount of time that it takes for the - Query API on the mock to process that a target has been deleted. - """ - # We choose a low time for a quick test. - query_processes_deletion = 0.1 - database = VuforiaDatabase() - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - requests.post(url=databases_url, json=database.to_dict()) - monkeypatch.setenv( - name='DELETION_PROCESSING_SECONDS', - value=str(query_processes_deletion), + vws_client = VWS( + server_access_key=data["server_access_key"], + server_secret_key=data["server_secret_key"], ) - time_taken = process_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + + cloud_reco_client = CloudRecoService( + client_access_key=data["client_access_key"], + client_secret_key=data["client_secret_key"], ) - expected = query_processes_deletion - assert abs(expected - time_taken) < self.LEEWAY + assert not vws_client.list_targets() + assert not cloud_reco_client.query(image=high_quality_image) -class TestAddDatabase: - """ - Tests for adding databases to the mock. - """ +class TestAddVuMarkDatabase: + """Tests for adding VuMark databases to the mock.""" - def test_duplicate_keys(self) -> None: + @staticmethod + def test_duplicate_keys() -> None: """ - It is not possible to have multiple databases with matching keys. + It is not possible to have multiple VuMark databases with + matching + keys. """ - database = VuforiaDatabase( - server_access_key='1', - server_secret_key='2', - client_access_key='3', - client_secret_key='4', - database_name='5', + database = VuMarkDatabase( + server_access_key="1", + server_secret_key="2", + database_name="3", ) - bad_server_access_key_db = VuforiaDatabase(server_access_key='1') - bad_server_secret_key_db = VuforiaDatabase(server_secret_key='2') - bad_client_access_key_db = VuforiaDatabase(client_access_key='3') - bad_client_secret_key_db = VuforiaDatabase(client_secret_key='4') - bad_database_name_db = VuforiaDatabase(database_name='5') + bad_server_access_key_db = VuMarkDatabase(server_access_key="1") + bad_server_secret_key_db = VuMarkDatabase(server_secret_key="2") + bad_database_name_db = VuMarkDatabase(database_name="3") server_access_key_conflict_error = ( - 'All server access keys must be unique. ' + "All server access keys must be unique. " 'There is already a database with the server access key "1".' ) server_secret_key_conflict_error = ( - 'All server secret keys must be unique. ' + "All server secret keys must be unique. " 'There is already a database with the server secret key "2".' ) - client_access_key_conflict_error = ( - 'All client access keys must be unique. ' - 'There is already a database with the client access key "3".' - ) - client_secret_key_conflict_error = ( - 'All client secret keys must be unique. ' - 'There is already a database with the client secret key "4".' - ) database_name_conflict_error = ( - 'All names must be unique. ' - 'There is already a database with the name "5".' + "All names must be unique. " + 'There is already a database with the name "3".' ) - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - requests.post(url=databases_url, json=database.to_dict()) + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) for bad_database, expected_message in ( (bad_server_access_key_db, server_access_key_conflict_error), (bad_server_secret_key_db, server_secret_key_conflict_error), - (bad_client_access_key_db, client_access_key_conflict_error), - (bad_client_secret_key_db, client_secret_key_conflict_error), (bad_database_name_db, database_name_conflict_error), ): response = requests.post( url=databases_url, json=bad_database.to_dict(), + timeout=30, ) assert response.status_code == HTTPStatus.CONFLICT assert response.text == expected_message - def test_give_no_details(self, high_quality_image: io.BytesIO) -> None: + +class TestDeleteCloudDatabase: + """Tests for deleting cloud databases from the mock.""" + + @staticmethod + def test_not_found() -> None: + """ + A 404 error is returned when trying to delete a cloud database + which does not exist. + """ + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + delete_url = databases_url + "/" + "foobar" + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + @staticmethod + def test_delete_cloud_database() -> None: + """It is possible to delete a cloud database.""" + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + response = requests.post(url=databases_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.CREATED + + data = json.loads(s=response.text) + delete_url = databases_url + "/" + data["database_name"] + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.OK + + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestDeleteVuMarkDatabase: + """Tests for deleting VuMark databases from the mock.""" + + @staticmethod + def test_not_found() -> None: """ - It is possible to create a database without giving any data. + A 404 error is returned when trying to delete a VuMark database + which does not exist. """ - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - response = requests.post(url=databases_url, json={}) + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + delete_url = databases_url + "/" + "foobar" + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + @staticmethod + def test_delete_vumark_database() -> None: + """It is possible to delete a VuMark database.""" + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + response = requests.post(url=databases_url, json={}, timeout=30) assert response.status_code == HTTPStatus.CREATED - data = response.json() + data = json.loads(s=response.text) + delete_url = databases_url + "/" + data["database_name"] + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.OK + + response = requests.delete(url=delete_url, json={}, timeout=30) + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestQueryImageMatchers: + """Tests for query image matchers.""" - assert data['targets'] == [] - assert data['state_name'] == 'WORKING' - assert 'database_name' in data.keys() + @staticmethod + def test_exact_match( + *, + high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The exact matcher matches only exactly the same images.""" + monkeypatch.setenv(name="QUERY_IMAGE_MATCHER", value="exact") + + database = CloudDatabase() vws_client = VWS( - server_access_key=data['server_access_key'], - server_secret_key=data['server_secret_key'], + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, ) + assert len(same_image_result) == 1 + different_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert not different_image_result + @staticmethod + def test_structural_similarity_matcher( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The structural similarity matcher matches similar images.""" + monkeypatch.setenv( + name="QUERY_IMAGE_MATCHER", + value="structural_similarity", + ) + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) cloud_reco_client = CloudRecoService( - client_access_key=data['client_access_key'], - client_secret_key=data['client_secret_key'], + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, ) - assert not vws_client.list_targets() - assert not cloud_reco_client.query(image=high_quality_image) + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + assert re_exported_image.getvalue() != high_quality_image.getvalue() -class TestDeleteDatabase: - """ - Tests for deleting databases from the mock. + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert len(same_image_result) == 1 + similar_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert len(similar_image_result) == 1 + + different_image_result = cloud_reco_client.query( + image=different_high_quality_image, + ) + assert not different_image_result + + +class TestDuplicatesImageMatchers: + """Tests for duplicates image matchers.""" + + @staticmethod + def test_exact_match( + *, + high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The exact matcher matches only exactly the same images.""" + monkeypatch.setenv(name="DUPLICATES_IMAGE_MATCHER", value="exact") + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + target_id = vws_client.add_target( + name="example_0", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + not_duplicate_target_id = vws_client.add_target( + name="example_2", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + vws_client.wait_for_target_processed( + target_id=not_duplicate_target_id, + ) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + @staticmethod + def test_structural_similarity_matcher( + *, + high_quality_image: io.BytesIO, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The structural similarity matcher matches similar images.""" + monkeypatch.setenv( + name="DUPLICATES_IMAGE_MATCHER", + value="structural_similarity", + ) + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + +class TestTargetRaters: + """Tests for using target raters.""" + + @staticmethod + def test_default( + *, + image_file_success_state_low_rating: io.BytesIO, + high_quality_image: io.BytesIO, + ) -> None: + """By default, the BRISQUE target rater is used.""" + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + low_rating_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + application_metadata=None, + active_flag=True, + ) + + high_quality_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + + for target_id in ( + low_rating_image_target_id, + high_quality_image_target_id, + ): + vws_client.wait_for_target_processed(target_id=target_id) + + low_rated_image_rating = vws_client.get_target_record( + target_id=low_rating_image_target_id, + ).target_record.tracking_rating + + high_quality_image_rating = vws_client.get_target_record( + target_id=high_quality_image_target_id, + ).target_record.tracking_rating + + assert low_rated_image_rating <= 0 + assert high_quality_image_rating > 1 + + @staticmethod + def test_brisque( + *, + monkeypatch: pytest.MonkeyPatch, + image_file_success_state_low_rating: io.BytesIO, + high_quality_image: io.BytesIO, + ) -> None: + """It is possible to use the BRISQUE target rater.""" + monkeypatch.setenv(name="TARGET_RATER", value="brisque") + + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + low_rating_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + application_metadata=None, + active_flag=True, + ) + + high_quality_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + + for target_id in ( + low_rating_image_target_id, + high_quality_image_target_id, + ): + vws_client.wait_for_target_processed(target_id=target_id) + + low_rated_image_rating = vws_client.get_target_record( + target_id=low_rating_image_target_id, + ).target_record.tracking_rating + + high_quality_image_rating = vws_client.get_target_record( + target_id=high_quality_image_target_id, + ).target_record.tracking_rating + + assert low_rated_image_rating <= 0 + assert high_quality_image_rating > 1 + + @staticmethod + def test_perfect( + *, + monkeypatch: pytest.MonkeyPatch, + high_quality_image: io.BytesIO, + ) -> None: + """It is possible to use the perfect target rater.""" + monkeypatch.setenv(name="TARGET_RATER", value="perfect") + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + for _ in range(50) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + ratings_set = { + vws_client.get_target_record( + target_id=target_id + ).target_record.tracking_rating + for target_id in target_ids + } + + assert ratings_set == {5} + + @staticmethod + def test_random( + *, + monkeypatch: pytest.MonkeyPatch, + high_quality_image: io.BytesIO, + ) -> None: + """It is possible to use the random target rater.""" + monkeypatch.setenv(name="TARGET_RATER", value="random") + + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + for _ in range(50) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + ratings = [ + vws_client.get_target_record( + target_id=target_id + ).target_record.tracking_rating + for target_id in target_ids + ] + + sorted_ratings = sorted(ratings) + lowest_rating = sorted_ratings[0] + highest_rating = sorted_ratings[-1] + minimum_rating = 0 + maximum_rating = 5 + assert lowest_rating >= minimum_rating + assert highest_rating <= maximum_rating + assert lowest_rating != highest_rating + + +class TestVuMarkTargetStatus: + """Tests for VuMark instance generation when target status is + validated (Flask app code path). """ - def test_not_found(self) -> None: - """ - A 404 error is returned when trying to delete a database which does not - exist. + @staticmethod + def test_processing_target_returns_forbidden() -> None: + """A VuMark target still processing returns 403 when generating + an instance via the Flask app. """ - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - delete_url = databases_url + '/' + 'foobar' - response = requests.delete(url=delete_url, json={}) - assert response.status_code == HTTPStatus.NOT_FOUND + vumark_target = VuMarkTarget( + name="processing-target", + processing_time_seconds=9999, + ) + vumark_database = VuMarkDatabase( + vumark_targets=set(), + ) - def test_delete_database(self) -> None: - """ - It is possible to delete a database. - """ - databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + '/databases' - response = requests.post(url=databases_url, json={}) + vumark_databases_url = ( + _EXAMPLE_URL_FOR_TARGET_MANAGER + "/vumark_databases" + ) + response = requests.post( + url=vumark_databases_url, + json=vumark_database.to_dict(), + timeout=30, + ) assert response.status_code == HTTPStatus.CREATED + database_data = json.loads(s=response.text) - data = response.json() - delete_url = databases_url + '/' + data['database_name'] - response = requests.delete(url=delete_url, json={}) - assert response.status_code == HTTPStatus.OK + vumark_targets_url = ( + f"{vumark_databases_url}" + f"/{database_data['database_name']}/vumark_targets" + ) + response = requests.post( + url=vumark_targets_url, + json=vumark_target.to_dict(), + timeout=30, + ) + assert response.status_code == HTTPStatus.CREATED - response = requests.delete(url=delete_url, json={}) - assert response.status_code == HTTPStatus.NOT_FOUND + request_path = f"/targets/{vumark_target.target_id}/instances" + content_type = "application/json" + content = json.dumps( + obj={"instance_id": uuid.uuid4().hex}, + ).encode(encoding="utf-8") + date = rfc_1123_date() + authorization_string = authorization_header( + access_key=vumark_database.server_access_key, + secret_key=vumark_database.server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + response = requests.post( + url="https://vws.vuforia.com" + request_path, + headers={ + "Accept": "image/png", + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + }, + data=content, + timeout=30, + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.TARGET_STATUS_NOT_SUCCESS.value + ) + + +class TestResponseDelay: + """Tests for the response delay feature. + + These tests run through the ``responses`` library, which intercepts + requests in-process. Because of this, the client ``timeout`` parameter + is not enforced — the delay blocks but never raises + ``requests.exceptions.Timeout``. When running the Flask app as a real + server (e.g. in Docker), the delay causes a genuinely slow HTTP + response and the ``requests`` client will raise ``Timeout`` on its own. + """ + + DELAY_SECONDS = 0.5 + + @staticmethod + def _make_request() -> None: + """Make a request to the VWS API.""" + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=30, + ) + + def test_default_no_delay(self) -> None: + """By default, there is no response delay.""" + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + start = time.monotonic() + self._make_request() + elapsed = time.monotonic() - start + assert elapsed < self.DELAY_SECONDS + + def test_delay_is_applied( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """When response_delay_seconds is set, the response is delayed.""" + monkeypatch.setenv( + name="RESPONSE_DELAY_SECONDS", + value=f"{self.DELAY_SECONDS}", + ) + database = CloudDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/cloud_databases" + requests.post(url=databases_url, json=database.to_dict(), timeout=30) + + start = time.monotonic() + self._make_request() + elapsed = time.monotonic() - start + assert elapsed >= self.DELAY_SECONDS diff --git a/tests/mock_vws/test_get_duplicates.py b/tests/mock_vws/test_get_duplicates.py index a068a3f6b..0c33383df 100644 --- a/tests/mock_vws/test_get_duplicates.py +++ b/tests/mock_vws/test_get_duplicates.py @@ -1,33 +1,28 @@ -""" -Tests for the mock of the get duplicates endpoint. -""" +"""Tests for the mock of the get duplicates endpoint.""" +import copy import io import uuid import pytest +from PIL import Image from vws import VWS -from vws.exceptions.vws_exceptions import ProjectInactive +from vws.exceptions.vws_exceptions import ProjectInactiveError from vws.reports import TargetStatuses -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDuplicates: - """ - Tests for the mock of the target duplicates endpoint. - """ + """Tests for the mock of the target duplicates endpoint.""" + @staticmethod def test_duplicates( - self, + *, high_quality_image: io.BytesIO, image_file_success_state_low_rating: io.BytesIO, vws_client: VWS, ) -> None: - """ - Target IDs of similar targets are returned. - - In the mock, "similar" means that the images are exactly the same. - """ + """Target IDs of the exact same targets are returned.""" image_data = high_quality_image different_image_data = image_file_success_state_low_rating @@ -65,14 +60,53 @@ def test_duplicates( assert duplicates == [similar_target_id] + @staticmethod + def test_duplicates_not_same( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + ) -> None: + """Target IDs of similar targets are returned.""" + image_data = high_quality_image + similar_image_data = copy.copy(x=image_data) + similar_image_buffer = io.BytesIO() + pil_similar_image = Image.open(fp=similar_image_data) + # Re-save means similar but not identical. + pil_similar_image.save(fp=similar_image_buffer, format="JPEG") + assert similar_image_buffer.getvalue() != image_data.getvalue() + + original_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_data, + active_flag=True, + application_metadata=None, + ) + + similar_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=similar_image_buffer, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=original_target_id) + vws_client.wait_for_target_processed(target_id=similar_target_id) + + duplicates = vws_client.get_duplicate_targets( + target_id=original_target_id, + ) + + assert duplicates == [similar_target_id] + + @staticmethod def test_status( - self, + *, image_file_failed_state: io.BytesIO, vws_client: VWS, ) -> None: - """ - Targets are not duplicates if the status is not 'success'. - """ + """Targets are not duplicates if the status is not 'success'.""" original_target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -104,22 +138,21 @@ def test_status( assert duplicates == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for the effects of the active flag on duplicate matching. - """ + """Tests for the effects of the active flag on duplicate matching.""" + @staticmethod def test_active_flag( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: - """ - Targets with `active_flag` set to `False` can have duplicates. - Targets with `active_flag` set to `False` are not found as duplicates. + """Targets with `active_flag` set to `False` can have duplicates. + Targets with `active_flag` set to `False` are not found as + duplicates. - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Check-for-Duplicate-Targets + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check says: ''' @@ -159,19 +192,18 @@ def test_active_flag( assert duplicates == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestProcessing: - """ - Tests for targets in the processing stage. - """ + """Tests for targets in the processing stage.""" + @staticmethod def test_processing( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, ) -> None: - """ - If a target is in the processing state, it can have duplicates. + """If a target is in the processing state, it can have duplicates. + Targets can have duplicates in the processing state. """ processed_target_id = vws_client.add_target( @@ -205,24 +237,24 @@ def test_processing( target_id=processing_target_id, ) + # There is a race condition here. + # If getting the target details and getting the duplicates takes longer + # than the processing time, the target will be in the success state. assert target_details.status == TargetStatuses.PROCESSING assert duplicates == [processed_target_id] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - with pytest.raises(ProjectInactive): + with pytest.raises(expected_exception=ProjectInactiveError): inactive_vws_client.get_duplicate_targets( target_id=uuid.uuid4().hex, ) diff --git a/tests/mock_vws/test_get_target.py b/tests/mock_vws/test_get_target.py index 5c6c675f7..325517864 100644 --- a/tests/mock_vws/test_get_target.py +++ b/tests/mock_vws/test_get_target.py @@ -1,7 +1,6 @@ -""" -Tests for getting a target record. +"""Tests for getting a target record. -https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Retrieve-a-Target-Record +https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record """ import io @@ -9,25 +8,22 @@ import pytest from vws import VWS -from vws.exceptions.vws_exceptions import UnknownTarget +from vws.exceptions.vws_exceptions import UnknownTargetError from vws.reports import TargetRecord, TargetStatuses -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestGetRecord: - """ - Tests for getting a target record. - """ + """Tests for getting a target record.""" + @staticmethod def test_get_vws_target( - self, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - Details of a target are returned. - """ - name = 'my_example_name' + """Details of a target are returned.""" + name = "my_example_name" width = 1234 target_id = vws_client.add_target( @@ -51,22 +47,24 @@ def test_get_vws_target( name=name, width=width, tracking_rating=tracking_rating, - reco_rating='', + reco_rating="", ) assert target_record == expected_target_record + @staticmethod def test_fail_status( - self, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - When a 1x1 image is given, the status changes from 'processing' to + When a 1x1 image is given, the status changes from 'processing' + to 'failed' after some time. """ target_id = vws_client.add_target( - name='my_example_name', + name="my_example_name", width=1, image=image_file_failed_state, active_flag=True, @@ -79,21 +77,23 @@ def test_fail_status( # Tracking rating is 0 when status is 'failed' assert target_details.target_record.tracking_rating == 0 + @staticmethod def test_success_status( - self, + *, image_file_success_state_low_rating: io.BytesIO, vws_client: VWS, ) -> None: - """ - When a random, large enough image is given, the status changes from + """When a random, large enough image is given, the status changes + from 'processing' to 'success' after some time. - The mock is much more lenient than the real implementation of VWS. - The test image does not prove that what is counted as a success in the - mock will be counted as a success in the real implementation. + The mock is much more lenient than the real implementation of + VWS. The test image does not prove that what is counted as a + success in the mock will be counted as a success in the real + implementation. """ target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file_success_state_low_rating, active_flag=True, @@ -114,18 +114,60 @@ def test_success_status( assert new_tracking_rating == tracking_rating -@pytest.mark.usefixtures('verify_mock_vuforia') -class TestInactiveProject: +def _get_target_tracking_rating( + *, + vws_client: VWS, + image_file: io.BytesIO, +) -> int: + """Get the tracking rating of a target with the given image.""" + target_id = vws_client.add_target( + name=f"example_{uuid.uuid4().hex}", + width=1, + image=image_file, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + target_details = vws_client.get_target_record(target_id=target_id) + return target_details.target_record.tracking_rating + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestTargetTrackingRating: """ - Tests for inactive projects. + Tests which exercise the target tracking_rating, and check the image + fixtures we use. """ - def test_inactive_project( - self, - inactive_vws_client: VWS, + @staticmethod + def test_target_quality( + *, + vws_client: VWS, + high_quality_image: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, ) -> None: - """ - The project's active state does not affect getting a target. - """ - with pytest.raises(UnknownTarget): + """The target tracking rating is as expected.""" + high_quality_image_tracking_rating = _get_target_tracking_rating( + vws_client=vws_client, + image_file=high_quality_image, + ) + low_quality_image_tracking_rating = _get_target_tracking_rating( + vws_client=vws_client, + image_file=image_file_success_state_low_rating, + ) + assert ( + high_quality_image_tracking_rating + > low_quality_image_tracking_rating + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestInactiveProject: + """Tests for inactive projects.""" + + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect getting a target.""" + with pytest.raises(expected_exception=UnknownTargetError): inactive_vws_client.get_target_record(target_id=uuid.uuid4().hex) diff --git a/tests/mock_vws/test_invalid_given_id.py b/tests/mock_vws/test_invalid_given_id.py index 954fa9c19..1d3b49e8c 100644 --- a/tests/mock_vws/test_invalid_given_id.py +++ b/tests/mock_vws/test_invalid_given_id.py @@ -1,44 +1,52 @@ """ -Tests for passing invalid target IDs to endpoints which -require a target ID to be given. +Tests for passing invalid target IDs to endpoints which require a target +ID to +be given. """ from http import HTTPStatus import pytest -import requests from vws import VWS from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import assert_vws_failure +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInvalidGivenID: """ - Tests for giving an invalid ID to endpoints which require a target ID to - be given. + Tests for giving an invalid ID to endpoints which require a target + ID to be + given. """ + @staticmethod def test_not_real_id( - self, + *, vws_client: VWS, endpoint: Endpoint, target_id: str, ) -> None: """ - A `NOT_FOUND` error is returned when an endpoint is given a target ID + A `NOT_FOUND` error is returned when an endpoint is given a + target ID of a target which does not exist. """ - if not endpoint.prepared_request.path_url.endswith(target_id): + # This shared check only covers endpoints that end in target_id, + # such as /targets/{target_id}. Endpoints with trailing segments + # are covered by endpoint-specific tests. + if not endpoint.path_url.endswith(target_id): return vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = endpoint.send() + + handle_server_errors(response=response) assert_vws_failure( response=response, diff --git a/tests/mock_vws/test_invalid_json.py b/tests/mock_vws/test_invalid_json.py index 312ac2ab9..35b3253cd 100644 --- a/tests/mock_vws/test_invalid_json.py +++ b/tests/mock_vws/test_invalid_json.py @@ -1,109 +1,191 @@ -""" -Tests for giving invalid JSON to endpoints. -""" +"""Tests for giving invalid JSON to endpoints.""" +import json from datetime import datetime, timedelta from http import HTTPStatus from urllib.parse import urlparse from zoneinfo import ZoneInfo import pytest -import requests from freezegun import freeze_time -from requests.structures import CaseInsensitiveDict from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import ( assert_valid_date_header, + assert_valid_transaction_id, assert_vwq_failure, assert_vws_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInvalidJSON: - """ - Tests for giving invalid JSON to endpoints. - """ - - @pytest.mark.parametrize('date_skew_minutes', [0, 10]) - def test_invalid_json( - self, - endpoint: Endpoint, - date_skew_minutes: int, - ) -> None: - """ - Giving invalid JSON to endpoints returns error responses. - """ - date_is_skewed = not date_skew_minutes == 0 - content = b'a' - gmt = ZoneInfo('GMT') + """Tests for giving invalid JSON to endpoints.""" + + @staticmethod + def test_invalid_json(endpoint: Endpoint) -> None: + """Giving invalid JSON to endpoints returns error responses.""" + content = b"a" + gmt = ZoneInfo(key="GMT") now = datetime.now(tz=gmt) - time_to_freeze = now + timedelta(minutes=date_skew_minutes) - with freeze_time(time_to_freeze): + time_to_freeze = now + with freeze_time(time_to_freeze=time_to_freeze): date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), + method=endpoint.method, content=content, content_type=endpoint.auth_header_content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), } - endpoint.prepared_request.body = content - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - endpoint.prepared_request.prepare_content_length(body=content) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) takes_json_data = ( - endpoint.auth_header_content_type == 'application/json' + endpoint.auth_header_content_type == "application/json" ) assert_valid_date_header(response=response) - if date_is_skewed and takes_json_data: - # On the real implementation, we get `HTTPStatus.FORBIDDEN` and - # `REQUEST_TIME_TOO_SKEWED`. - # See https://github.com/VWS-Python/vws-python-mock/issues/4 for - # implementing this on them mock. - return - - if not date_is_skewed and takes_json_data: + if takes_json_data: + expected_result_code = ( + ResultCodes.BAD_REQUEST + if endpoint.path_url.endswith("/instances") + else ResultCodes.FAIL + ) assert_vws_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - result_code=ResultCodes.FAIL, + result_code=expected_result_code, ) return - assert response.status_code == HTTPStatus.BAD_REQUEST - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) - expected_text = 'No image.' + expected_text = "No image." assert response.text == expected_text return - assert response.text == '' - assert 'Content-Type' not in response.headers + assert response.status_code == HTTPStatus.BAD_REQUEST + assert not response.text + assert "Content-Type" not in response.headers + + @staticmethod + def test_invalid_json_with_skewed_time(endpoint: Endpoint) -> None: + """Giving invalid JSON to endpoints returns error responses.""" + # We use a skew of 70 because the maximum allowed skew for services is + # 5 minutes, and for query is 65 minutes. 70 is comfortably larger than + # the max of these two. + date_skew_minutes = 70 + content = b"a" + gmt = ZoneInfo(key="GMT") + now = datetime.now(tz=gmt) + time_to_freeze = now + timedelta(minutes=date_skew_minutes) + with freeze_time(time_to_freeze=time_to_freeze): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=content, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + takes_json_data = ( + endpoint.auth_header_content_type == "application/json" + ) + + assert_valid_date_header(response=response) + + if takes_json_data: + assert_vws_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + result_code=ResultCodes.REQUEST_TIME_TOO_SKEWED, + ) + return + + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == { + "transaction_id", + "result_code", + } + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert not response.text + assert "Content-Type" not in response.headers diff --git a/tests/mock_vws/test_query.py b/tests/mock_vws/test_query.py index a52d2f7ac..4c47ad168 100644 --- a/tests/mock_vws/test_query.py +++ b/tests/mock_vws/test_query.py @@ -1,69 +1,82 @@ -""" -Tests for the mock of the query endpoint. +"""Tests for the mock of the query endpoint. -https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. +https://developer.vuforia.com/library/web-api/vuforia-query-web-api. """ -from __future__ import annotations - import base64 import calendar +import copy import datetime import io +import json +import re import textwrap import time import uuid -from http import HTTPStatus -from pathlib import Path -from typing import Any, Dict +from http import HTTPMethod, HTTPStatus +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin from zoneinfo import ZoneInfo import pytest import requests +from dirty_equals import IsInstance from PIL import Image -from requests import Response -from requests_mock import POST +from tenacity import Retrying +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed from urllib3.filepost import encode_multipart_formdata from vws import VWS, CloudRecoService +from vws.exceptions.cloud_reco_exceptions import ( + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, +) +from vws.exceptions.custom_exceptions import RequestEntityTooLargeError from vws.reports import TargetStatuses +from vws.response import Response from vws_auth_tools import authorization_header, rfc_1123_date -from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from tests.mock_vws.utils import make_image_file from tests.mock_vws.utils.assertions import ( assert_query_success, - assert_valid_date_header, assert_valid_transaction_id, assert_vwq_failure, ) +from tests.mock_vws.utils.too_many_requests import handle_server_errors -VWQ_HOST = 'https://cloudreco.vuforia.com' +if TYPE_CHECKING: + from collections.abc import Iterable + +VWQ_HOST = "https://cloudreco.vuforia.com" _JETTY_CONTENT_TYPE_ERROR = textwrap.dedent( - """\ + text="""\ - + Error 400 Bad Request -

HTTP ERROR 400 Bad Request

+ +

HTTP ERROR 400 Bad Request

URI:/v1/query
STATUS:500
MESSAGE: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]
- + -
URI:/v1/query
URI:http://cloudreco.vuforia.com/v1/query
STATUS:400
MESSAGE:Bad Request
SERVLET:Resteasy
-
Powered by Jetty:// 9.4.43.v20210629
+
Powered by Jetty:// 12.0.20
- """, # noqa: E501 + """, ) +_JETTY_VERSION_RE = re.compile(pattern=r"Powered by Jetty:// [\d.]+") + _NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR = textwrap.dedent( - """\ + text="""\ \r 413 Request Entity Too Large\r \r @@ -74,20 +87,13 @@ """, ) -_JETTY_ERROR_DELETION_NOT_COMPLETE_START_PATH = ( - Path(__file__).parent / 'jetty_error_deletion_not_complete.html' -) -_JETTY_ERROR_DELETION_NOT_COMPLETE = ( - _JETTY_ERROR_DELETION_NOT_COMPLETE_START_PATH.read_text() -) - -def query( - vuforia_database: VuforiaDatabase, - body: Dict[str, Any], +def _query( + *, + vuforia_database: CloudDatabase, + body: dict[str, Any], ) -> Response: - """ - Make a request to the endpoint to make an image recognition query. + """Make a request to the endpoint to make an image recognition query. Args: vuforia_database: The credentials to use to connect to @@ -98,9 +104,9 @@ def query( The response returned by the API. """ date = rfc_1123_date() - request_path = '/v1/query' - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -110,102 +116,107 @@ def query( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, } - vwq_host = 'https://cloudreco.vuforia.com' - response = requests.request( + vwq_host = "https://cloudreco.vuforia.com" + requests_response = requests.request( method=method, url=urljoin(base=vwq_host, url=request_path), headers=headers, data=content, + timeout=30, ) - return response + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + return vws_response -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestContentType: - """ - Tests for the Content-Type header. - """ + """Tests for the Content-Type header.""" + @staticmethod @pytest.mark.parametrize( - [ - 'content_type', - 'resp_status_code', - 'resp_content_type', - 'resp_cache_control', - 'resp_text', - ], - [ + argnames=( + "content_type", + "resp_status_code", + "resp_content_type", + "resp_cache_control", + "resp_text", + ), + argvalues=[ ( - 'text/html', + "text/html", HTTPStatus.UNSUPPORTED_MEDIA_TYPE, None, None, - '', + "", ), ( - '', + "", HTTPStatus.BAD_REQUEST, - 'text/html;charset=iso-8859-1', - 'must-revalidate,no-cache,no-store', + "text/html;charset=iso-8859-1", + "must-revalidate,no-cache,no-store", _JETTY_CONTENT_TYPE_ERROR, ), ( - '*/*', + "*/*", HTTPStatus.BAD_REQUEST, - 'text/html;charset=utf-8', + "text/plain;charset=utf-8", None, - ( - 'java.io.IOException: RESTEASY007550: Unable to get ' - 'boundary for multipart' - ), + "Unable to get boundary for multipart", ), ( - 'text/*', + "text/*", HTTPStatus.UNSUPPORTED_MEDIA_TYPE, None, None, - '', + "", ), ( - 'text/plain', + "text/plain", HTTPStatus.UNSUPPORTED_MEDIA_TYPE, None, None, - '', + "", ), ], ) def test_incorrect_no_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, content_type: str, resp_status_code: int, resp_content_type: str | None, resp_cache_control: str | None, resp_text: str, ) -> None: - """ - With bad Content-Type headers we get a variety of results. - """ + """With bad Content-Type headers we get a variety of results.""" image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, _ = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -220,46 +231,66 @@ def test_incorrect_no_boundary( ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, ) - assert response.text == resp_text + handle_server_errors(response=vws_response) + + repl = "Powered by Jetty://" + sub = _JETTY_VERSION_RE.sub + actual = sub(repl=repl, string=requests_response.text) + expected = sub(repl=repl, string=resp_text) + assert actual == expected assert_vwq_failure( - response=response, + response=vws_response, status_code=resp_status_code, content_type=resp_content_type, cache_control=resp_cache_control, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) + @staticmethod def test_incorrect_with_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - If a Content-Type header which is not ``multipart/form-data`` is given - with the correct boundary, an ``UNSUPPORTED_MEDIA_TYPE`` response is + If a Content-Type header which is not ``multipart/form-data`` is + given + with the correct boundary, an ``UNSUPPORTED_MEDIA_TYPE`` response + is given. """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST - content_type = 'text/html' + content_type = "text/html" access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -273,55 +304,65 @@ def test_incorrect_with_boundary( request_path=request_path, ) - _, boundary = content_type_header.split(';') + _, boundary = content_type_header.split(sep=";") - content_type = 'text/html; ' + boundary + content_type = "text/html; " + boundary headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert response.text == '' + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert not requests_response.text assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, content_type=None, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) + @staticmethod @pytest.mark.parametrize( - 'content_type', - [ - 'multipart/form-data', - 'multipart/form-data; extra', - 'multipart/form-data; extra=1', + argnames="content_type", + argvalues=[ + "multipart/form-data", + "multipart/form-data; extra", + "multipart/form-data; extra=1", ], ) def test_no_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, content_type: str, ) -> None: - """ - If no boundary is given, a ``BAD_REQUEST`` is returned. - """ + """If no boundary is given, a ``BAD_REQUEST`` is returned.""" image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, _ = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -331,52 +372,58 @@ def test_no_boundary( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - expected_text = ( - 'java.io.IOException: RESTEASY007550: ' - 'Unable to get boundary for multipart' + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, ) - assert response.text == expected_text + expected_text = "Unable to get boundary for multipart" + assert requests_response.text == expected_text assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.BAD_REQUEST, - content_type='text/html;charset=utf-8', + content_type="text/plain;charset=utf-8", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) + @staticmethod def test_bogus_boundary( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: - """ - If a bogus boundary is given, a ``BAD_REQUEST`` is returned. - """ + """If a bogus boundary is given, a ``BAD_REQUEST`` is returned.""" image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, _ = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -386,50 +433,64 @@ def test_bogus_boundary( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': 'multipart/form-data; boundary=example_boundary', + "Authorization": authorization_string, + "Date": date, + "Content-Type": "multipart/form-data; boundary=example_boundary", } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - expected_text = 'No image.' - assert response.text == expected_text + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + + expected_text = "No image." + assert requests_response.text == expected_text assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.BAD_REQUEST, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) + @staticmethod def test_extra_section( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - If sections that are not the boundary section are given in the header, + If sections that are not the boundary section are given in the + header, that is fine. """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -439,107 +500,202 @@ def test_extra_section( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header + '; extra=1', + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header + "; extra=1", } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert_query_success(response=response) - assert response.json()['results'] == [] + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestSuccess: - """ - Tests for successful calls to the query endpoint. - """ + """Tests for successful calls to the query endpoint.""" + @staticmethod def test_no_results( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + cloud_reco_client: CloudRecoService, ) -> None: """ - When there are no matching images in the database, an empty list of + When there are no matching images in the database, an empty list + of results is returned. """ - image_content = high_quality_image.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] - - def test_match( - self, + @staticmethod + def test_match_exact( + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, vws_client: VWS, ) -> None: """ - If the exact image that was added is queried for, target data is shown. + If the exact high quality image that was added is queried for, + target + data is shown. """ - image_content = high_quality_image.getvalue() - metadata_encoded = base64.b64encode(b'example').decode('ascii') - name = 'example_name' + image_file = high_quality_image + image_content = image_file.getvalue() + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) + name = "example_name" target_id = vws_client.add_target( name=name, width=1, - image=high_quality_image, + image=image_file, active_flag=True, application_metadata=metadata_encoded, ) - approximate_target_created = calendar.timegm(time.gmtime()) + approximate_target_created = calendar.timegm(tuple=time.gmtime()) vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + body = {"image": ("image.jpeg", image_content, "image/jpeg")} - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - [result] = response.json()['results'] - assert result.keys() == {'target_id', 'target_data'} - assert result['target_id'] == target_id - target_data = result['target_data'] - assert target_data.keys() == { - 'application_metadata', - 'name', - 'target_timestamp', + response_json = json.loads(s=response.text) + (result,) = response_json["results"] + assert result == { + "target_id": target_id, + "target_data": { + "application_metadata": metadata_encoded, + "name": name, + "target_timestamp": IsInstance(expected_type=int), + }, } - assert target_data['application_metadata'] == metadata_encoded - assert target_data['name'] == name - target_timestamp = target_data['target_timestamp'] - assert isinstance(target_timestamp, int) + target_timestamp = int(result["target_data"]["target_timestamp"]) time_difference = abs(approximate_target_created - target_timestamp) - assert time_difference < 5 + max_time_difference = 5 + assert time_difference < max_time_difference + + @staticmethod + def test_low_quality_image( + *, + image_file_success_state_low_rating: io.BytesIO, + cloud_reco_client: CloudRecoService, + vws_client: VWS, + ) -> None: + """ + If the exact low quality image that was added is queried for, no + results are returned. + """ + image_file = image_file_success_state_low_rating + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) + name = "example_name" + + target_id = vws_client.add_target( + name=name, + width=1, + image=image_file, + active_flag=True, + application_metadata=metadata_encoded, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + matching_targets = cloud_reco_client.query(image=image_file) + assert not matching_targets + + @staticmethod + def test_match_similar( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + ) -> None: + """ + If a similar image to one that was added is queried for, target + data is + shown. + """ + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) + name_matching = "example_name_matching" + name_not_matching = "example_name_not_matching" + target_id_matching = vws_client.add_target( + name=name_matching, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=metadata_encoded, + ) + + target_id_not_matching = vws_client.add_target( + name=name_not_matching, + width=1, + image=different_high_quality_image, + active_flag=True, + application_metadata=metadata_encoded, + ) + + vws_client.wait_for_target_processed(target_id=target_id_matching) + vws_client.wait_for_target_processed(target_id=target_id_not_matching) + + similar_image_buffer = io.BytesIO() + similar_image_data = copy.copy(x=high_quality_image) + pil_similar_image = Image.open(fp=similar_image_data) + # Re-save means similar but not identical. + pil_similar_image.save(fp=similar_image_buffer, format="JPEG") + + (matching_target,) = cloud_reco_client.query( + image=similar_image_buffer, + max_num_results=5, + ) + + assert matching_target.target_id == target_id_matching + + @staticmethod def test_not_base64_encoded_processable( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, not_base64_encoded_processable: str, + cloud_reco_client: CloudRecoService, ) -> None: """ - Vuforia accepts some metadata strings which are not valid base64. - When a target with such a string is matched by a query, Vuforia returns + Vuforia accepts some metadata strings which are not valid + base64. + When a target with such a string is matched by a query, Vuforia + returns an interesting result: * If the metadata string is a length one greater than a multiple of 4, @@ -549,11 +705,8 @@ def test_not_base64_encoded_processable( * If the metadata is three greater than a multiple of 4, the result is padded, then decoded, then encoded. """ - image_content = high_quality_image.getvalue() - name = 'example_name' - target_id = vws_client.add_target( - name=name, + name="example_name", width=1, image=high_quality_image, active_flag=True, @@ -562,120 +715,113 @@ def test_not_base64_encoded_processable( vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - [result] = response.json()['results'] - query_metadata = result['target_data']['application_metadata'] - if len(not_base64_encoded_processable) % 4 == 1: - expected_metadata_original = not_base64_encoded_processable[:-1] - elif len(not_base64_encoded_processable) % 4 == 2: - expected_metadata_original = not_base64_encoded_processable + '==' - else: - assert len(not_base64_encoded_processable) % 4 == 3 - expected_metadata_original = not_base64_encoded_processable + '=' + query_results = cloud_reco_client.query(image=high_quality_image) + (result,) = query_results + assert result.target_data is not None + query_metadata = result.target_data.application_metadata + mod_4_to_expected_metadata_original = { + 1: not_base64_encoded_processable[:-1], + 2: not_base64_encoded_processable + "==", + 3: not_base64_encoded_processable + "=", + } + expected_metadata_original = mod_4_to_expected_metadata_original[ + len(not_base64_encoded_processable) % 4 + ] expected_metadata = base64.b64encode( - base64.b64decode(expected_metadata_original), + s=base64.b64decode(s=expected_metadata_original), ) assert query_metadata == expected_metadata.decode() -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestIncorrectFields: - """ - Tests for incorrect and unexpected fields. - """ + """Tests for incorrect and unexpected fields.""" - def test_missing_image( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_missing_image(vuforia_database: CloudDatabase) -> None: """ - If an image is not given, a ``BAD_REQUEST`` response is returned. + If an image is not given, a ``BAD_REQUEST`` response is + returned. """ - response = query(vuforia_database=vuforia_database, body={}) + response = _query(vuforia_database=vuforia_database, body={}) - assert response.text == 'No image.' + assert response.text == "No image." assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) + @staticmethod def test_extra_fields( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - If extra fields are given, a ``BAD_REQUEST`` response is returned. + If extra fields are given, a ``BAD_REQUEST`` response is + returned. """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'extra_field': (None, 1, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "extra_field": (None, 1, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) - assert response.text == 'Unknown parameters in the request.' + assert response.text == "Unknown parameters in the request." assert_vwq_failure( response=response, - content_type='application/json', + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) + @staticmethod def test_missing_image_and_extra_fields( - self, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: - """ - If extra fields are given and no image field is given, a + """If extra fields are given and no image field is given, a ``BAD_REQUEST`` response is returned. The extra field error takes precedence. """ body = { - 'extra_field': (None, 1, 'text/plain'), + "extra_field": (None, 1, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) - assert response.text == 'Unknown parameters in the request.' + assert response.text == "Unknown parameters in the request." assert_vwq_failure( response=response, - content_type='application/json', + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMaxNumResults: - """ - Tests for the ``max_num_results`` parameter. - """ + """Tests for the ``max_num_results`` parameter.""" + @staticmethod def test_default( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, vws_client: VWS, ) -> None: - """ - The default ``max_num_results`` is 1. - """ + """The default ``max_num_results`` is 1.""" image_content = high_quality_image.getvalue() target_id_1 = vws_client.add_target( @@ -696,158 +842,157 @@ def test_default( vws_client.wait_for_target_processed(target_id=target_id_2) body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), + "image": ("image.jpeg", image_content, "image/jpeg"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - assert len(response.json()['results']) == 1 + response_json = json.loads(s=response.text) + assert len(response_json["results"]) == 1 - @pytest.mark.parametrize('num_results', [1, b'1', 50]) + @staticmethod + @pytest.mark.parametrize(argnames="num_results", argvalues=[1, b"1", 50]) def test_valid_accepted( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, num_results: int | bytes, ) -> None: - """ - Numbers between 1 and 50 are valid inputs. + """Numbers between 1 and 50 are valid inputs. - We assert that the response is a success, but not that the maximum - number of results is enforced. + We assert that the response is a success, but not that the + maximum number of results is enforced. This is because uploading 50 images would be very slow. The documentation at - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query # noqa: E501 + https://developer.vuforia.com/library/web-api/vuforia-query-web-api states that this must be between 1 and 10, but in practice, 50 is the maximum. """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, num_results, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, num_results, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - assert response.json()['results'] == [] + response_json = json.loads(s=response.text) + assert response_json["results"] == [] + @staticmethod def test_valid_works( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - A maximum of ``max_num_results`` results are returned. - """ - image_content = high_quality_image.getvalue() - add_and_wait_for_targets( + """A maximum of ``max_num_results`` results are returned.""" + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=3, ) - body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, 2, 'text/plain'), - } + max_num_results = 2 - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert len(response.json()['results']) == 2 + result = cloud_reco_client.query( + image=high_quality_image, + max_num_results=max_num_results, + ) + assert len(result) == max_num_results - @pytest.mark.parametrize('num_results', [-1, 0, 51]) + @staticmethod + @pytest.mark.parametrize(argnames="num_results", argvalues=[-1, 0, 51]) def test_out_of_range( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, num_results: int, + cloud_reco_client: CloudRecoService, ) -> None: - """ - An error is returned if ``max_num_results`` is given as an integer out - of the range (1, 50). + """An error is returned if ``max_num_results`` is given as an + integer + out of the range (1, 50). The documentation at - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. # noqa: E501 + https://developer.vuforia.com/library/web-api/vuforia-query-web-api. states that this must be between 1 and 10, but in practice, 50 is the maximum. """ - image_content = high_quality_image.getvalue() - body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, num_results, 'text/plain'), - } - - response = query(vuforia_database=vuforia_database, body=body) + with pytest.raises( + expected_exception=MaxNumResultsOutOfRangeError, + ) as exc_info: + cloud_reco_client.query( + image=high_quality_image, + max_num_results=num_results, + ) expected_text = ( - f'Integer out of range ({repr(num_results)}) in form data part ' + f"Integer out of range ({num_results}) in form data part " "'max_result'. Accepted range is from 1 to 50 (inclusive)." ) - assert response.text == expected_text + assert exc_info.value.response.text == expected_text assert_vwq_failure( - response=response, - content_type='application/json', + response=exc_info.value.response, + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) + @staticmethod @pytest.mark.parametrize( - 'num_results', - [b'0.1', b'1.1', b'a', b'2147483648'], + argnames="num_results", + argvalues=[b"0.1", b"1.1", b"a", b"2147483648"], ) def test_invalid_type( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, num_results: bytes, ) -> None: - """ - An error is returned if ``max_num_results`` is given as something other - than an integer. + """An error is returned if ``max_num_results`` is given as + something + other than an integer. - Integers greater than 2147483647 are not considered integers because - they are bigger than Java's maximum integer. + Integers greater than 2147483647 are not considered integers + because they are bigger than Java's maximum integer. """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, num_results, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, num_results, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) expected_text = ( f"Invalid value '{num_results.decode()}' in form data part " "'max_result'. " - 'Expecting integer value in range from 1 to 50 (inclusive).' + "Expecting integer value in range from 1 to 50 (inclusive)." ) assert response.text == expected_text assert_vwq_failure( response=response, - content_type='application/json', + content_type="application/json", status_code=HTTPStatus.BAD_REQUEST, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) -def add_and_wait_for_targets( +def _add_and_wait_for_targets( + *, image: io.BytesIO, vws_client: VWS, num_targets: int, ) -> None: - """ - Add targets with the given image. - """ - target_ids = set() + """Add targets with the given image.""" + target_ids: Iterable[str] = set() for _ in range(num_targets): target_id = vws_client.add_target( name=uuid.uuid4().hex, @@ -856,159 +1001,180 @@ def add_and_wait_for_targets( active_flag=True, application_metadata=None, ) - target_ids.add(target_id) + target_ids = {*target_ids, target_id} - for target_id in target_ids: - vws_client.wait_for_target_processed(target_id=target_id) + for created_target_id in target_ids: + vws_client.wait_for_target_processed(target_id=created_target_id) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestIncludeTargetData: - """ - Tests for the ``include_target_data`` parameter. - """ + """Tests for the ``include_target_data`` parameter.""" + @staticmethod def test_default( - self, + *, high_quality_image: io.BytesIO, vws_client: VWS, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: - """ - The default ``include_target_data`` is 'top'. - """ - add_and_wait_for_targets( + """The default ``include_target_data`` is 'top'.""" + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' in result_1 - assert 'target_data' not in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" not in result_2 - @pytest.mark.parametrize('include_target_data', ['top', 'TOP']) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["top", "TOP"], + ) def test_top( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, include_target_data: str, vws_client: VWS, ) -> None: """ - When ``include_target_data`` is set to "top" (case insensitive), only + When ``include_target_data`` is set to "top" (case insensitive), + only the first result includes target data. """ - add_and_wait_for_targets( + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' in result_1 - assert 'target_data' not in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" not in result_2 - @pytest.mark.parametrize('include_target_data', ['none', 'NONE']) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["none", "NONE"], + ) def test_none( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, include_target_data: str, vws_client: VWS, ) -> None: """ - When ``include_target_data`` is set to "none" (case insensitive), no + When ``include_target_data`` is set to "none" (case + insensitive), no results include target data. """ - add_and_wait_for_targets( + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' not in result_1 - assert 'target_data' not in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" not in result_1 + assert "target_data" not in result_2 - @pytest.mark.parametrize('include_target_data', ['all', 'ALL']) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["all", "ALL"], + ) def test_all( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, include_target_data: str, vws_client: VWS, ) -> None: """ - When ``include_target_data`` is set to "all" (case insensitive), all + When ``include_target_data`` is set to "all" (case insensitive), + all results include target data. """ - add_and_wait_for_targets( + _add_and_wait_for_targets( image=high_quality_image, vws_client=vws_client, num_targets=2, ) image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), - 'max_num_results': (None, 2, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()['results'] - assert 'target_data' in result_1 - assert 'target_data' in result_2 + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" in result_2 - @pytest.mark.parametrize('include_target_data', ['a', True, 0]) + @staticmethod + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["a", True, 0], + ) def test_invalid_value( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - include_target_data: Any, + vuforia_database: CloudDatabase, + include_target_data: str | bool | int, ) -> None: """ - A ``BAD_REQUEST`` error is given when a string that is not one of + A ``BAD_REQUEST`` error is given when a string that is not one + of 'none', 'top' or 'all' (case insensitive). """ image_content = high_quality_image.getvalue() body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'include_target_data': (None, include_target_data, 'text/plain'), + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), } - response = query(vuforia_database=vuforia_database, body=body) + response = _query(vuforia_database=vuforia_database, body=body) expected_text = ( - f"Invalid value '{include_target_data}' in form data " - "part 'include_target_data'. " + f"Invalid value '{str(object=include_target_data).lower()}' in " + "form data part 'include_target_data'. " "Expecting one of the (unquoted) string values 'all', 'none' or " "'top'." ) @@ -1016,43 +1182,42 @@ def test_invalid_value( assert_vwq_failure( response=response, status_code=HTTPStatus.BAD_REQUEST, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestAcceptHeader: - """ - Tests for the ``Accept`` header. - """ + """Tests for the ``Accept`` header.""" + @staticmethod @pytest.mark.parametrize( - 'extra_headers', - [ + argnames="extra_headers", + argvalues=[ { - 'Accept': 'application/json', + "Accept": "application/json", }, {}, ], ) def test_valid( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - extra_headers: Dict[str, str], + vuforia_database: CloudDatabase, + extra_headers: dict[str, str], ) -> None: - """ - An ``Accept`` header can be given iff its value is "application/json". + """An ``Accept`` header can be given iff its value is + "application/json". """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -1062,42 +1227,55 @@ def test_valid( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, - **extra_headers, - } + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + } | extra_headers - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert_query_success(response=response) - assert response.json()['results'] == [] + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] + @staticmethod def test_invalid( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, ) -> None: """ - A NOT_ACCEPTABLE response is returned if an ``Accept`` header is given + A NOT_ACCEPTABLE response is returned if an ``Accept`` header is + given with a value which is not "application/json". """ image_content = high_quality_image.getvalue() date = rfc_1123_date() - request_path = '/v1/query' - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - content, content_type_header = encode_multipart_formdata(body) - method = POST + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -1107,51 +1285,59 @@ def test_invalid( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, - 'Accept': 'text/html', + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + "Accept": "text/html", } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.NOT_ACCEPTABLE, content_type=None, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for active versus inactive targets. - """ + """Tests for active versus inactive targets.""" + @staticmethod def test_inactive( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - Images which are not active are not matched. - """ - image_content = high_quality_image.getvalue() + """Images which are not active are not matched.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1161,87 +1347,72 @@ def test_inactive( ) vws_client.wait_for_target_processed(target_id=target_id) + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) - assert_query_success(response=response) - assert response.json()['results'] == [] - - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestBadImage: - """ - Tests for bad images. - """ + """Tests for bad images.""" + @staticmethod def test_corrupted( - self, - vuforia_database: VuforiaDatabase, + *, corrupted_image_file: io.BytesIO, + cloud_reco_client: CloudRecoService, ) -> None: - """ - No error is returned when a corrupted image is given. - """ - corrupted_data = corrupted_image_file.getvalue() - - body = {'image': ('image.jpeg', corrupted_data, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) + """No error is returned when a corrupted image is given.""" + results = cloud_reco_client.query(image=corrupted_image_file) + assert results == [] - assert_query_success(response=response) - assert response.json()['results'] == [] - - def test_not_image( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_not_image(cloud_reco_client: CloudRecoService) -> None: """ - No error is returned when a corrupted image is given. + An ``UNPROCESSABLE_ENTITY`` response is returned when a non- + image is + given. """ - not_image_data = b'not_image_data' + not_image_data = b"not_image_data" - body = {'image': ('image.jpeg', not_image_data, 'image/jpeg')} + with pytest.raises(expected_exception=BadImageError) as exc_info: + cloud_reco_client.query( + image=io.BytesIO(initial_bytes=not_image_data) + ) - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMaximumImageFileSize: - """ - Tests for maximum image file sizes. - """ + """Tests for maximum image file sizes.""" - def test_png( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_png(cloud_reco_client: CloudRecoService) -> None: """ According to - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. + https://developer.vuforia.com/library/web-api/vuforia-query-web- + api. the maximum file size is "2MiB for PNG". Above this limit, a ``REQUEST_ENTITY_TOO_LARGE`` response is returned. @@ -1251,14 +1422,13 @@ def test_png( max_bytes = 2 * 1024 * 1024 width = height = 835 png_not_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) image_content = png_not_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly smaller than the @@ -1269,22 +1439,19 @@ def test_png( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=png_not_too_large) + assert results == [] width += 1 height += 1 png_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) image_content = png_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly larger than the # maximum file size. @@ -1294,28 +1461,29 @@ def test_png( assert image_content_size > max_bytes assert (image_content_size * 0.95) < max_bytes - response = query( - vuforia_database=vuforia_database, - body=body, - ) + with pytest.raises( + expected_exception=RequestEntityTooLargeError + ) as exc_info: + cloud_reco_client.query(image=png_too_large) + + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, - content_type='text/html', + content_type="text/html", cache_control=None, www_authenticate=None, - connection='Close', + connection="Close", ) assert response.text == _NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR - def test_jpeg( - self, - vuforia_database: VuforiaDatabase, - ) -> None: + @staticmethod + def test_jpeg(cloud_reco_client: CloudRecoService) -> None: """ According to - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. + https://developer.vuforia.com/library/web-api/vuforia-query-web- + api. the maximum file size is "512 KiB for JPEG". However, this test shows that the maximum size for JPEG is 2 MiB. @@ -1326,14 +1494,13 @@ def test_jpeg( max_bytes = 2 * 1024 * 1024 width = height = 1865 jpeg_not_too_large = make_image_file( - file_format='JPEG', - color_space='RGB', + file_format="JPEG", + color_space="RGB", width=width, height=height, ) image_content = jpeg_not_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly smaller than the @@ -1344,21 +1511,18 @@ def test_jpeg( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=jpeg_not_too_large) + assert results == [] width = height = 1866 jpeg_too_large = make_image_file( - file_format='JPEG', - color_space='RGB', + file_format="JPEG", + color_space="RGB", width=width, height=height, ) image_content = jpeg_too_large.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} image_content_size = len(image_content) # We check that the image we created is just slightly larger than the # maximum file size. @@ -1368,237 +1532,233 @@ def test_jpeg( assert image_content_size > max_bytes assert (image_content_size * 0.95) < max_bytes - response = query( - vuforia_database=vuforia_database, - body=body, - ) + with pytest.raises( + expected_exception=RequestEntityTooLargeError + ) as exc_info: + cloud_reco_client.query(image=jpeg_too_large) + + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, - content_type='text/html', + content_type="text/html", cache_control=None, www_authenticate=None, - connection='Close', + connection="Close", ) assert response.text == _NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestMaximumImageDimensions: - """ - Tests for maximum image dimensions. - """ + """Tests for maximum image dimensions.""" - def test_max_height(self, vuforia_database: VuforiaDatabase) -> None: + @staticmethod + def test_max_height( + cloud_reco_client: CloudRecoService, + ) -> None: """ - An error is returned when an image with a height greater than 30000 is + An error is returned when an image with a height greater than + 30000 is given. """ width = 1 max_height = 30000 png_not_too_tall = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=max_height, ) - image_content = png_not_too_tall.getvalue() - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=png_not_too_tall) + assert results == [] png_too_tall = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=max_height + 1, ) - image_content = png_too_tall.getvalue() - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + with pytest.raises(expected_exception=BadImageError) as exc_info: + cloud_reco_client.query(image=png_too_tall) - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text - def test_max_width(self, vuforia_database: VuforiaDatabase) -> None: + @staticmethod + def test_max_width(cloud_reco_client: CloudRecoService) -> None: """ - An error is returned when an image with a width greater than 30000 is + An error is returned when an image with a width greater than + 30000 is given. """ height = 1 max_width = 30000 png_not_too_wide = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=max_width, height=height, ) - image_content = png_not_too_wide.getvalue() - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] + result = cloud_reco_client.query(image=png_not_too_wide) + assert result == [] png_too_wide = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=max_width + 1, height=height, ) - image_content = png_too_wide.getvalue() - - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + with pytest.raises(expected_exception=BadImageError) as exc_info: + result = cloud_reco_client.query(image=png_too_wide) - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text + @staticmethod + def test_max_pixels(cloud_reco_client: CloudRecoService) -> None: + """No error is returned for an 835 x 835 image.""" + # If we make this 836 then we hit REQUEST_ENTITY_TOO_LARGE errors. + max_height = max_width = 835 + png_not_too_wide = make_image_file( + file_format="PNG", + color_space="RGB", + width=max_width, + height=max_height, + ) -@pytest.mark.usefixtures('verify_mock_vuforia') + result = cloud_reco_client.query(image=png_not_too_wide) + assert result == [] + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestImageFormats: - """ - Tests for various image formats. - """ + """Tests for various image formats.""" - @pytest.mark.parametrize('file_format', ['png', 'jpeg']) + @staticmethod + @pytest.mark.parametrize(argnames="file_format", argvalues=["png", "jpeg"]) def test_supported( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, file_format: str, + cloud_reco_client: CloudRecoService, ) -> None: - """ - PNG and JPEG formats are supported. - """ + """PNG and JPEG formats are supported.""" image_buffer = io.BytesIO() - pil_image = Image.open(high_quality_image) - pil_image.save(image_buffer, file_format) + pil_image = Image.open(fp=high_quality_image) + pil_image.save(fp=image_buffer, format=file_format) image_content = image_buffer.getvalue() + results = cloud_reco_client.query( + image=io.BytesIO(initial_bytes=image_content) + ) + assert results == [] - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] - + @staticmethod def test_unsupported( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + cloud_reco_client: CloudRecoService, ) -> None: - """ - File formats which are not PNG or JPEG are not supported. - """ - file_format = 'tiff' + """File formats which are not PNG or JPEG are not supported.""" + file_format = "tiff" image_buffer = io.BytesIO() - pil_image = Image.open(high_quality_image) - pil_image.save(image_buffer, file_format) + pil_image = Image.open(fp=high_quality_image) + pil_image.save(fp=image_buffer, format=file_format) image_content = image_buffer.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + with pytest.raises(expected_exception=BadImageError) as exc_info: + cloud_reco_client.query( + image=io.BytesIO(initial_bytes=image_content) + ) - response = query(vuforia_database=vuforia_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.BAD_IMAGE.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" ) assert response.text == expected_text -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestProcessing: - """ - Tests for targets in the processing state. - """ + """Tests for targets in the processing state.""" - @pytest.mark.parametrize( - 'active_flag', - [True, False], - ) + @staticmethod + @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_processing( - self, + *, high_quality_image: io.BytesIO, - active_flag: bool, vws_client: VWS, cloud_reco_client: CloudRecoService, + active_flag: bool, ) -> None: """ - When a target with a matching image is in the processing state it is + When a target with a matching image is in the processing state + it is not matched. """ target_id = vws_client.add_target( @@ -1628,28 +1788,28 @@ def test_processing( assert matching_targets == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUpdate: - """ - Tests for updated targets. - """ + """Tests for updated targets.""" + @staticmethod def test_updated_target( - self, + *, high_quality_image: io.BytesIO, different_high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: + """After a target is updated, only the new image can be matched. + + The match result includes the updated name, timestamp and + application metadata. """ - After a target is updated, only the new image can be matched. - The match result includes the updated name, timestamp and application - metadata. - """ - image_content = high_quality_image.getvalue() - metadata = b'example_metadata' - metadata_encoded = base64.b64encode(metadata).decode('ascii') - name = 'example_name' + metadata = b"example_metadata" + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) + name = "example_name" target_id = vws_client.add_target( name=name, width=1, @@ -1658,22 +1818,20 @@ def test_updated_target( application_metadata=metadata_encoded, ) - calendar.timegm(time.gmtime()) + calendar.timegm(tuple=time.gmtime()) vws_client.wait_for_target_processed(target_id=target_id) - new_image_content = different_high_quality_image.getvalue() - - new_name = name + '2' - new_metadata = metadata + b'2' - new_metadata_encoded = base64.b64encode(new_metadata).decode('ascii') + new_name = name + "2" + new_metadata = metadata + b"2" + new_metadata_encoded = base64.b64encode(s=new_metadata).decode( + encoding="ascii" + ) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) - [result] = response.json()['results'] - target_data = result['target_data'] - target_timestamp = target_data['target_timestamp'] - original_target_timestamp = int(target_timestamp) + results = cloud_reco_client.query(image=high_quality_image) + (result,) = results + assert result.target_data is not None + original_target_timestamp = result.target_data.target_timestamp vws_client.update_target( target_id=target_id, @@ -1682,101 +1840,44 @@ def test_updated_target( application_metadata=new_metadata_encoded, ) - approximate_target_updated = calendar.timegm(time.gmtime()) + approximate_target_updated = calendar.timegm(tuple=time.gmtime()) vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', new_image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) + results = cloud_reco_client.query(image=different_high_quality_image) - assert_query_success(response=response) - [result] = response.json()['results'] - assert result.keys() == {'target_id', 'target_data'} - assert result['target_id'] == target_id - target_data = result['target_data'] - assert target_data.keys() == { - 'application_metadata', - 'name', - 'target_timestamp', - } - assert target_data['application_metadata'] == new_metadata_encoded - assert target_data['name'] == new_name - target_timestamp = target_data['target_timestamp'] - assert isinstance(target_timestamp, int) + (result,) = results + assert result.target_data is not None + assert result.target_data.application_metadata == new_metadata_encoded + assert result.target_data.name == new_name + target_timestamp = result.target_data.target_timestamp # In the future we might want to test that # target_timestamp > original_target_timestamp # However, this requires us to set the mock processing time at > 1 # second. assert target_timestamp >= original_target_timestamp - time_difference = abs(approximate_target_updated - target_timestamp) - assert time_difference < 5 + time_difference = abs( + approximate_target_updated - target_timestamp.timestamp(), + ) + max_time_difference = 5 + assert time_difference < max_time_difference - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - response = query(vuforia_database=vuforia_database, body=body) - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDeleted: - """ - Tests for matching deleted targets. - """ - - def test_deleted( - self, - high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, - vws_client: VWS, - ) -> None: - """ - Within approximately 7 seconds of deleting a target, querying for its - image results in an ``INTERNAL_SERVER_ERROR``. - """ - image_content = high_quality_image.getvalue() - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) + """Tests for matching deleted targets.""" - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - while True: - response = query(vuforia_database=vuforia_database, body=body) - # Sometimes the first response(s) include the old target. - try: - assert_query_success(response=response) - except AssertionError: - assert response.text.startswith( - _JETTY_ERROR_DELETION_NOT_COMPLETE, - ) - assert_vwq_failure( - response=response, - content_type='text/html;charset=iso-8859-1', - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - cache_control='must-revalidate,no-cache,no-store', - www_authenticate=None, - connection='keep-alive', - ) - - return - - def test_deleted_and_wait( - self, + @staticmethod + def test_deleted_active( + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - After waiting approximately 7 seconds (we wait more to be safer), a - deleted target is not found when its image is queried for. - """ - image_content = high_quality_image.getvalue() + """Deleted targets are not matched.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1787,64 +1888,37 @@ def test_deleted_and_wait( vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - # In practice, we have seen a delay of up to 30 seconds between - # deleting a target and getting a valid response which has a result - # array without the deleted item. - total_waited = 0 - - # We wait up to 60 seconds to be safe to avoid indefinite waits and - # using up our quota. - max_wait_seconds = 60 - - # We do not want to retry immediately else we risk using our request - # quota. - sleep_seconds = 2 - - server_error_seen = False - - while True: - response = query(vuforia_database=vuforia_database, body=body) - - try: - assert_query_success(response=response) - except AssertionError: - server_error_seen = True - assert response.text.startswith( - _JETTY_ERROR_DELETION_NOT_COMPLETE, - ) - time.sleep(sleep_seconds) - total_waited += sleep_seconds - else: - if response.json()['results']: - [result] = response.json()['results'] - assert result['target_id'] == target_id - # We never see the target ID after having seen the server - # error. - assert not server_error_seen - time.sleep(sleep_seconds) - total_waited += sleep_seconds - else: - assert response.json()['results'] == [] - break - - assert total_waited < max_wait_seconds - - # The deletion never takes effect immediately. - assert total_waited + # There is a race condition here. + # In the real Vuforia, it takes some time for the target deletion + # to be reflected in the query endpoint. + # + # This difference is documented in ``differences-to-vws.rst``. + # + # We retry to allow for this difference. + for attempt in Retrying( + wait=wait_fixed(wait=0.1), + stop=stop_after_delay(max_delay=3), + retry=retry_if_exception_type( + exception_types=(AssertionError,), + ), + reraise=True, + ): + with attempt: + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] + @staticmethod def test_deleted_inactive( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: """ - No error is returned when querying for an image of recently deleted, + No error is returned when querying for an image of recently + deleted, inactive target. """ - image_content = high_quality_image.getvalue() target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1854,31 +1928,22 @@ def test_deleted_inactive( ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - - assert_query_success(response=response) - assert response.json()['results'] == [] - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetStatusFailed: - """ - Tests for targets with the status "failed". - """ + """Tests for targets with the status "failed".""" + @staticmethod def test_status_failed( - self, + *, image_file_failed_state: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, + cloud_reco_client: CloudRecoService, ) -> None: - """ - Targets with the status "failed" are not found in query results. - """ - image_content = image_file_failed_state.getvalue() + """Targets with the status "failed" are not found in query results.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -1888,17 +1953,13 @@ def test_status_failed( ) vws_client.wait_for_target_processed(target_id=target_id) - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} - - response = query(vuforia_database=vuforia_database, body=body) - assert_query_success(response=response) - assert response.json()['results'] == [] + results = cloud_reco_client.query(image=image_file_failed_state) + assert results == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestDateFormats: - """ - Tests for various date formats. + """Tests for various date formats. The date format for the VWS API as per https://library.vuforia.com/articles/Training/Using-the-VWS-API.html must @@ -1907,45 +1968,45 @@ class TestDateFormats: However, for the query endpoint, the documentation does not mention the format. It says: - > The data format must exactly match the Date that is sent in the ‘Date’ + > The data format must exactly match the Date that is sent in the `Date` > header. """ + @staticmethod @pytest.mark.parametrize( - 'datetime_format', - [ - '%a, %b %d %H:%M:%S %Y', - '%a %b %d %H:%M:%S %Y', - '%a, %d %b %Y %H:%M:%S', - '%a %d %b %Y %H:%M:%S', + argnames="datetime_format", + argvalues=[ + "%a, %b %d %H:%M:%S %Y", + "%a %b %d %H:%M:%S %Y", + "%a, %d %b %Y %H:%M:%S", + "%a %d %b %Y %H:%M:%S", ], ) - @pytest.mark.parametrize('include_tz', [True, False]) + @pytest.mark.parametrize(argnames="include_tz", argvalues=[True, False]) def test_date_formats( - self, + *, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, datetime_format: str, include_tz: bool, ) -> None: - """ - Test various date formats which are known to be accepted. + """Test various date formats which are known to be accepted. - We expect that more formats than this will be accepted. - These are the accepted ones we know of at the time of writing. + We expect that more formats than this will be accepted. These + are the accepted ones we know of at the time of writing. """ image_content = high_quality_image.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + body = {"image": ("image.jpeg", image_content, "image/jpeg")} if include_tz: - datetime_format += ' GMT' + datetime_format += " GMT" - gmt = ZoneInfo('GMT') + gmt = ZoneInfo(key="GMT") now = datetime.datetime.now(tz=gmt) - date = now.strftime(datetime_format) - request_path = '/v1/query' - content, content_type_header = encode_multipart_formdata(body) - method = POST + date = now.strftime(format=datetime_format) + request_path = "/v1/query" + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST access_key = vuforia_database.client_access_key secret_key = vuforia_database.client_secret_key @@ -1954,66 +2015,80 @@ def test_date_formats( secret_key=secret_key, method=method, content=content, - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, data=content, + timeout=30, ) - assert_query_success(response=response) - assert response.json()['results'] == [] + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" + @staticmethod def test_inactive_project( - self, - inactive_database: VuforiaDatabase, + *, high_quality_image: io.BytesIO, + inactive_cloud_reco_client: CloudRecoService, ) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - image_content = high_quality_image.getvalue() - body = {'image': ('image.jpeg', image_content, 'image/jpeg')} + with pytest.raises( + expected_exception=InactiveProjectError + ) as exc_info: + inactive_cloud_reco_client.query(image=high_quality_image) - response = query(vuforia_database=inactive_database, body=body) + response = exc_info.value.response assert_vwq_failure( response=response, status_code=HTTPStatus.FORBIDDEN, - content_type='application/json', + content_type="application/json", cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) - assert response.json().keys() == {'transaction_id', 'result_code'} + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + + assert response_json.keys() == {"transaction_id", "result_code"} assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - result_code = response.json()['result_code'] - transaction_id = response.json()['transaction_id'] - assert result_code == ResultCodes.INACTIVE_PROJECT.value # The separators are inconsistent and we test this. expected_text = ( '{"transaction_id": ' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - '}' + f'"{response_json["transaction_id"]}",' + f'"result_code":"InactiveProject"' + "}" ) assert response.text == expected_text diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index 7a2444a35..eaa82a494 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -1,77 +1,90 @@ -""" -Tests for the usage of the mock for ``requests``. -""" +"""Tests for the usage of the mock for ``requests``.""" +import datetime import email.utils import io import json import socket -from datetime import datetime +from http import HTTPStatus +from urllib.parse import urlparse +import httpx import pytest import requests +from beartype import beartype from freezegun import freeze_time -from requests.exceptions import MissingSchema -from requests_mock.exceptions import NoMockAddress -from vws import VWS -from vws_auth_tools import rfc_1123_date - -from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase -from mock_vws.states import States -from mock_vws.target import Target +from PIL import Image +from vws import VWS, CloudRecoService +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws import MissingSchemeError, MockVWS +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.image_matchers import ExactMatcher, StructuralSimilarityMatcher +from mock_vws.target import ImageTarget, VuMarkTarget +from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.usage_test_helpers import ( - process_deletion_seconds, processing_time_seconds, - recognize_deletion_seconds, ) +@beartype +def _not_exact_matcher( + first_image_content: bytes, + second_image_content: bytes, +) -> bool: + """A matcher which returns True if the images are not the same.""" + return first_image_content != second_image_content + + +@beartype def request_unmocked_address() -> None: - """ - Make a request, using `requests` to an unmocked, free local address. + """Make a request, using `requests` to an unmocked, free local address. Raises: requests.exceptions.ConnectionError: This is expected as there is nothing to connect to. - requests_mock.exceptions.NoMockAddress: This request is being made in - the context of a `requests_mock` mock which does not mock local + requests.exceptions.ConnectionError: This request is being made in the + context of a ``responses`` mock which does not mock local addresses. """ sock = socket.socket() - sock.bind(('', 0)) + sock.bind(("", 0)) port = sock.getsockname()[1] sock.close() - address = f'http://localhost:{port}' - requests.get(address) + requests.get(url=f"http://localhost:{port}", timeout=30) +@beartype def request_mocked_address() -> None: """ - Make a request, using `requests` to an address that is mocked by `MockVWS`. + Make a request, using `requests` to an address that is mocked by + `MockVWS`. """ requests.get( - url='https://vws.vuforia.com/summary', + url="https://vws.vuforia.com/summary", headers={ - 'Date': rfc_1123_date(), - 'Authorization': 'bad_auth_token', + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", }, - data=b'', + data=b"", + timeout=30, ) class TestRealHTTP: - """ - Tests for making requests to mocked and unmocked addresses. - """ + """Tests for making requests to mocked and unmocked addresses.""" - def test_default(self) -> None: + @staticmethod + def test_default() -> None: """ By default, the mock stops any requests made with `requests` to - non-Vuforia addresses, but not to mocked Vuforia endpoints. + non- + Vuforia addresses, but not to mocked Vuforia endpoints. """ with MockVWS(): - with pytest.raises(NoMockAddress): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): request_unmocked_address() # No exception is raised when making a request to a mocked @@ -79,287 +92,373 @@ def test_default(self) -> None: request_mocked_address() # The mocking stops when the context manager stops. - with pytest.raises(requests.exceptions.ConnectionError): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): request_unmocked_address() - def test_real_http(self) -> None: + @staticmethod + def test_real_http() -> None: """ - When the `real_http` parameter given to the context manager is set to + When the `real_http` parameter given to the context manager is + set to `True`, requests made to unmocked addresses are not stopped. """ - with MockVWS(real_http=True): - with pytest.raises(requests.exceptions.ConnectionError): - request_unmocked_address() + with ( + MockVWS(real_http=True), + pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ), + ): + request_unmocked_address() + + +class TestResponseDelay: + """Tests for the response delay feature.""" + + @staticmethod + def test_default_no_delay() -> None: + """By default, there is no response delay.""" + with MockVWS(): + # With a very short timeout, the request should still succeed + # because there is no delay + response = requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=0.5, + ) + # We just care that no timeout occurred, not the response content + assert response.status_code is not None + + @staticmethod + def test_delay_causes_timeout() -> None: + """ + When response_delay_seconds is set higher than the client + timeout, + a Timeout exception is raised. + """ + with ( + MockVWS(response_delay_seconds=0.5), + pytest.raises(expected_exception=requests.exceptions.Timeout), + ): + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=0.1, + ) + + @staticmethod + def test_delay_allows_completion() -> None: + """ + When response_delay_seconds is set lower than the client + timeout, + the request completes successfully. + """ + with MockVWS(response_delay_seconds=0.1): + # This should succeed because timeout > delay + response = requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=2.0, + ) + assert response.status_code is not None + + @staticmethod + def test_delay_with_tuple_timeout() -> None: + """ + The response delay works correctly with tuple timeouts + (connect_timeout, read_timeout). + """ + with ( + MockVWS(response_delay_seconds=0.5), + pytest.raises(expected_exception=requests.exceptions.Timeout), + ): + # Tuple timeout: (connect_timeout, read_timeout) + # The read timeout (0.1) is less than the delay (0.5) + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=(5.0, 0.1), + ) + + @staticmethod + def test_custom_sleep_fn_called_on_delay() -> None: + """ + When a custom ``sleep_fn`` is provided, it is called instead of + ``time.sleep`` for the non-timeout delay path. + """ + calls: list[float] = [] + with MockVWS( + response_delay_seconds=5.0, + sleep_fn=calls.append, + ): + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=30, + ) + assert calls == [5.0] + + @staticmethod + def test_custom_sleep_fn_called_on_timeout() -> None: + """ + When a custom ``sleep_fn`` is provided, it is called instead of + ``time.sleep`` for the timeout path. + """ + calls: list[float] = [] + with ( + MockVWS( + response_delay_seconds=5.0, + sleep_fn=calls.append, + ), + pytest.raises(expected_exception=requests.exceptions.Timeout), + ): + requests.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + data=b"", + timeout=1.0, + ) + # sleep_fn should have been called with the effective timeout + assert calls == [1.0] class TestProcessingTime: - """ - Tests for the time taken to process targets in the mock. - """ + """Tests for the time taken to process targets in the mock.""" # There is a race condition in this test type - if tests start to # fail, consider increasing the leeway. - LEEWAY = 0.1 + LEEWAY = 0.5 def test_default(self, image_file_failed_state: io.BytesIO) -> None: - """ - By default, targets in the mock take 0.5 seconds to be processed. - """ - database = VuforiaDatabase() + """By default, targets in the mock takes 2 seconds to be processed.""" + database = CloudDatabase() with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) time_taken = processing_time_seconds( vuforia_database=database, image=image_file_failed_state, ) - expected = 0.5 - assert abs(expected - time_taken) < self.LEEWAY + expected = 2 + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY def test_custom(self, image_file_failed_state: io.BytesIO) -> None: - """ - It is possible to set a custom processing time. - """ - database = VuforiaDatabase() - with MockVWS(processing_time_seconds=0.1) as mock: - mock.add_database(database=database) + """It is possible to set a custom processing time.""" + database = CloudDatabase() + seconds = 5 + with MockVWS(processing_time_seconds=seconds) as mock: + mock.add_cloud_database(cloud_database=database) time_taken = processing_time_seconds( vuforia_database=database, image=image_file_failed_state, ) - expected = 0.1 - assert abs(expected - time_taken) < self.LEEWAY + expected = seconds + assert expected - self.LEEWAY < time_taken < expected + self.LEEWAY class TestDatabaseName: - """ - Tests for the database name. - """ + """Tests for the database name.""" - def test_default(self) -> None: - """ - By default, the database has a random name. - """ - database_details = VuforiaDatabase() - other_database_details = VuforiaDatabase() + @staticmethod + def test_default() -> None: + """By default, the database has a random name.""" + database_details = CloudDatabase() + other_database_details = CloudDatabase() assert ( database_details.database_name != other_database_details.database_name ) - def test_custom_name(self) -> None: - """ - It is possible to set a custom database name. - """ - database_details = VuforiaDatabase(database_name='foo') - assert database_details.database_name == 'foo' + @staticmethod + def test_custom_name() -> None: + """It is possible to set a custom database name.""" + database_details = CloudDatabase(database_name="foo") + assert database_details.database_name == "foo" class TestCustomBaseURLs: - """ - Tests for using custom base URLs. - """ + """Tests for using custom base URLs.""" - def test_custom_base_vws_url(self) -> None: - """ - It is possible to use a custom base VWS URL. - """ + @staticmethod + def test_custom_base_vws_url() -> None: + """It is possible to use a custom base VWS URL.""" with MockVWS( - base_vws_url='https://vuforia.vws.example.com', + base_vws_url="https://vuforia.vws.example.com", real_http=False, ): - with pytest.raises(NoMockAddress): - requests.get('https://vws.vuforia.com/summary') + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.get(url="https://vws.vuforia.com/summary", timeout=30) - requests.get(url='https://vuforia.vws.example.com/summary') - requests.post('https://cloudreco.vuforia.com/v1/query') + requests.get( + url="https://vuforia.vws.example.com/summary", + timeout=30, + ) + requests.post( + url="https://cloudreco.vuforia.com/v1/query", + timeout=30, + ) - def test_custom_base_vwq_url(self) -> None: - """ - It is possible to use a custom base cloud recognition URL. - """ + @staticmethod + def test_custom_base_vwq_url() -> None: + """It is possible to use a custom base cloud recognition URL.""" with MockVWS( - base_vwq_url='https://vuforia.vwq.example.com', + base_vwq_url="https://vuforia.vwq.example.com", real_http=False, ): - with pytest.raises(NoMockAddress): - requests.post('https://cloudreco.vuforia.com/v1/query') - - requests.post(url='https://vuforia.vwq.example.com/v1/query') - requests.get('https://vws.vuforia.com/summary') - - def test_no_scheme(self) -> None: - """ - An error if raised if a URL is given with no scheme. - """ - with pytest.raises(MissingSchema) as exc: - MockVWS(base_vws_url='vuforia.vws.example.com') - - expected = ( - 'Invalid URL "vuforia.vws.example.com": No scheme supplied. ' - 'Perhaps you meant "https://vuforia.vws.example.com".' - ) - assert str(exc.value) == expected - with pytest.raises(MissingSchema) as exc: - MockVWS(base_vwq_url='vuforia.vwq.example.com') - expected = ( - 'Invalid URL "vuforia.vwq.example.com": No scheme supplied. ' - 'Perhaps you meant "https://vuforia.vwq.example.com".' - ) - assert str(exc.value) == expected - - -class TestCustomQueryRecognizesDeletionSeconds: - """ - Tests for setting the amount of time after a target has been deleted - until it is not recognized by the query endpoint. - """ - - LEEWAY = 0.15 - - def test_default( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - By default it takes zero seconds for the Query API on the mock to - recognize that a target has been deleted. - - The real Query API takes between zero and two seconds. - See ``test_query`` for more information. - """ - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - time_taken = recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.post( + url="https://cloudreco.vuforia.com/v1/query", + timeout=30, + ) + + requests.post( + url="https://vuforia.vwq.example.com/v1/query", + timeout=30, ) - - expected = 0.2 - assert abs(expected - time_taken) < self.LEEWAY - - def test_with_no_processing_time( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - This exercises some otherwise untouched code. - """ - database = VuforiaDatabase() - with MockVWS(query_processes_deletion_seconds=0) as mock: - mock.add_database(database=database) - time_taken = recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + requests.get( + url="https://vws.vuforia.com/summary", + timeout=30, ) - expected = 0.2 - assert abs(expected - time_taken) < self.LEEWAY - - def test_custom( - self, - high_quality_image: io.BytesIO, - ) -> None: + @staticmethod + def test_custom_base_vws_url_with_path_prefix() -> None: + """A custom base VWS URL with a path prefix intercepts at the + prefix. """ - It is possible to use set a custom amount of time that it takes for the - Query API on the mock to recognize that a target has been deleted. - """ - # We choose a low time for a quick test. - query_recognizes_deletion = 0.5 - database = VuforiaDatabase() with MockVWS( - query_recognizes_deletion_seconds=query_recognizes_deletion, - ) as mock: - mock.add_database(database=database) - time_taken = recognize_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + base_vws_url="https://vuforia.vws.example.com/prefix", + real_http=False, + ): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.get( + url="https://vuforia.vws.example.com/summary", + timeout=30, + ) + + requests.get( + url="https://vuforia.vws.example.com/prefix/summary", + timeout=30, ) - expected = query_recognizes_deletion - assert abs(expected - time_taken) < self.LEEWAY - - -class TestCustomQueryProcessDeletionSeconds: - """ - Tests for setting the amount of time after a target has been deleted - until it is not processed by the query endpoint. - """ - - # There is a race condition in this test type - if tests start to - # fail, consider increasing the leeway. - LEEWAY = 0.2 - - def test_default( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - By default it takes three seconds for the Query API on the mock to - process that a target has been deleted. - - The real Query API takes between seven and thirty seconds. - See ``test_query`` for more information. + @staticmethod + def test_custom_base_vwq_url_with_path_prefix() -> None: + """A custom base VWQ URL with a path prefix intercepts at the + prefix. """ - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - time_taken = process_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + with MockVWS( + base_vwq_url="https://vuforia.vwq.example.com/prefix", + real_http=False, + ): + with pytest.raises( + expected_exception=requests.exceptions.ConnectionError + ): + requests.post( + url="https://vuforia.vwq.example.com/v1/query", + timeout=30, + ) + + requests.post( + url="https://vuforia.vwq.example.com/prefix/v1/query", + timeout=30, ) - expected = 3 - assert abs(expected - time_taken) < self.LEEWAY - - def test_custom( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - It is possible to use set a custom amount of time that it takes for the - Query API on the mock to process that a target has been deleted. + @staticmethod + def test_vws_operations_work_with_path_prefix() -> None: + """VWS API operations work correctly with a base URL path + prefix. """ - # We choose a low time for a quick test. - query_processes_deletion = 0.1 - database = VuforiaDatabase() - with MockVWS( - query_processes_deletion_seconds=query_processes_deletion, - ) as mock: - mock.add_database(database=database) - time_taken = process_deletion_seconds( - high_quality_image=high_quality_image, - vuforia_database=database, + database = CloudDatabase() + base_vws_url = "https://vuforia.vws.example.com/prefix" + + with MockVWS(base_vws_url=base_vws_url) as mock: + mock.add_cloud_database(cloud_database=database) + + request_path = "/targets" + date = rfc_1123_date() + auth = authorization_header( + access_key=database.server_access_key, + secret_key=database.server_secret_key, + method="GET", + content=b"", + content_type="", + date=date, + request_path=request_path, + ) + response = requests.get( + url=base_vws_url + request_path, + headers={ + "Authorization": auth, + "Date": date, + }, + timeout=30, ) - expected = query_processes_deletion - assert abs(expected - time_taken) < self.LEEWAY + assert response.status_code == HTTPStatus.OK + response_json = response.json() + assert response_json["result_code"] == "Success" + assert response_json["results"] == [] + @staticmethod + def test_no_scheme() -> None: + """An error if raised if a URL is given with no scheme.""" + with pytest.raises(expected_exception=MissingSchemeError) as vws_exc: + MockVWS(base_vws_url="vuforia.vws.example.com") -class TestStates: - """ - Tests for different mock states. - """ - - def test_repr(self) -> None: - """ - The representation of a ``State`` shows the state. - """ - assert repr(States.WORKING) == '' + expected = ( + 'Invalid URL "vuforia.vws.example.com": No scheme supplied. ' + 'Perhaps you meant "https://vuforia.vws.example.com".' + ) + assert str(object=vws_exc.value) == expected + with pytest.raises(expected_exception=MissingSchemeError) as vwq_exc: + MockVWS(base_vwq_url="vuforia.vwq.example.com") + expected = ( + 'Invalid URL "vuforia.vwq.example.com": No scheme supplied. ' + 'Perhaps you meant "https://vuforia.vwq.example.com".' + ) + assert str(object=vwq_exc.value) == expected class TestTargets: - """ - Tests for target representations. - """ + """Tests for target representations.""" - def test_to_dict(self, high_quality_image: io.BytesIO) -> None: + @staticmethod + def test_to_dict(high_quality_image: io.BytesIO) -> None: """ - It is possible to dump a target to a dictionary and load it back. + It is possible to dump a target to a dictionary and load it + back. """ - database = VuforiaDatabase() + database = CloudDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -367,30 +466,34 @@ def test_to_dict(self, high_quality_image: io.BytesIO) -> None: ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) vws_client.add_target( - name='example', + name="example", width=1, image=high_quality_image, active_flag=True, application_metadata=None, ) - (target,) = database.targets + assert len(database.targets) == 1 + target = next(iter(database.targets)) + assert isinstance(target, ImageTarget) target_dict = target.to_dict() # The dictionary is JSON dump-able - assert json.dumps(target_dict) + assert json.dumps(obj=target_dict) - new_target = Target.from_dict(target_dict=target_dict) + new_target = ImageTarget.from_dict(target_dict=target_dict) assert new_target == target - def test_to_dict_deleted(self, high_quality_image: io.BytesIO) -> None: + @staticmethod + def test_to_dict_deleted(high_quality_image: io.BytesIO) -> None: """ - It is possible to dump a deleted target to a dictionary and load it + It is possible to dump a deleted target to a dictionary and load + it back. """ - database = VuforiaDatabase() + database = CloudDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -398,9 +501,9 @@ def test_to_dict_deleted(self, high_quality_image: io.BytesIO) -> None: ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) target_id = vws_client.add_target( - name='example', + name="example", width=1, image=high_quality_image, active_flag=True, @@ -409,26 +512,44 @@ def test_to_dict_deleted(self, high_quality_image: io.BytesIO) -> None: vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - (target,) = database.targets + assert len(database.targets) == 1 + target = next(iter(database.targets)) + assert isinstance(target, ImageTarget) target_dict = target.to_dict() # The dictionary is JSON dump-able - assert json.dumps(target_dict) + assert json.dumps(obj=target_dict) - new_target = Target.from_dict(target_dict=target_dict) + new_target = ImageTarget.from_dict(target_dict=target_dict) assert new_target.delete_date == target.delete_date + @staticmethod + def test_vumark_target_to_dict() -> None: + """It is possible to dump a VuMark target to a dictionary and + load it back. + """ + vumark_target = VuMarkTarget( + name="example-vumark", + processing_time_seconds=5.0, + ) + target_dict = vumark_target.to_dict() + + assert json.dumps(obj=target_dict) + + new_target = VuMarkTarget.from_dict(target_dict=target_dict) + assert new_target == vumark_target + class TestDatabaseToDict: - """ - Tests for dumping a database to a dictionary. - """ + """Tests for dumping a database to a dictionary.""" - def test_to_dict(self, high_quality_image: io.BytesIO) -> None: + @staticmethod + def test_to_dict(high_quality_image: io.BytesIO) -> None: """ - It is possible to dump a database to a dictionary and load it back. + It is possible to dump a database to a dictionary and load it + back. """ - database = VuforiaDatabase() + database = CloudDatabase() vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, @@ -436,9 +557,9 @@ def test_to_dict(self, high_quality_image: io.BytesIO) -> None: # We test a database with a target added. with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) vws_client.add_target( - name='example', + name="example", width=1, image=high_quality_image, active_flag=True, @@ -447,80 +568,106 @@ def test_to_dict(self, high_quality_image: io.BytesIO) -> None: database_dict = database.to_dict() # The dictionary is JSON dump-able - assert json.dumps(database_dict) + assert json.dumps(obj=database_dict) + + new_database = CloudDatabase.from_dict(database_dict=database_dict) + assert new_database == database + + @staticmethod + def test_vumark_database_to_dict() -> None: + """It is possible to dump a VuMark database to a dictionary and + load it back. + """ + vumark_target = VuMarkTarget( + name="example-vumark", + processing_time_seconds=3.0, + ) + database = VuMarkDatabase( + vumark_targets={vumark_target}, + ) + + database_dict = database.to_dict() + assert json.dumps(obj=database_dict) - new_database = VuforiaDatabase.from_dict(database_dict=database_dict) + new_database = VuMarkDatabase.from_dict(database_dict=database_dict) assert new_database == database class TestDateHeader: - """ - Tests for the date header in responses from mock routes. - """ + """Tests for the date header in responses from mock routes.""" - def test_date_changes(self) -> None: + @staticmethod + def test_date_changes() -> None: """ - The date that the response is sent is in the response Date header. + The date that the response is sent is in the response Date + header. """ new_year = 2012 - new_time = datetime(new_year, 1, 1) - with MockVWS(): - with freeze_time(new_time): - response = requests.get('https://vws.vuforia.com/summary') + new_time = datetime.datetime( + year=new_year, + month=1, + day=1, + tzinfo=datetime.UTC, + ) + with MockVWS(), freeze_time(time_to_freeze=new_time): + response = requests.get( + url="https://vws.vuforia.com/summary", + timeout=30, + ) - date_response = response.headers['Date'] - date_from_response = email.utils.parsedate(date_response) + date_response = response.headers["Date"] + date_from_response = email.utils.parsedate(data=date_response) assert date_from_response is not None year = date_from_response[0] assert year == new_year class TestAddDatabase: - """ - Tests for adding databases to the mock. - """ + """Tests for adding databases to the mock.""" - def test_duplicate_keys(self) -> None: + @staticmethod + def test_duplicate_keys() -> None: """ - It is not possible to have multiple databases with matching keys. + It is not possible to have multiple databases with matching + keys. """ - database = VuforiaDatabase( - server_access_key='1', - server_secret_key='2', - client_access_key='3', - client_secret_key='4', - database_name='5', + database = CloudDatabase( + server_access_key="1", + server_secret_key="2", + client_access_key="3", + client_secret_key="4", + database_name="5", ) - bad_server_access_key_db = VuforiaDatabase(server_access_key='1') - bad_server_secret_key_db = VuforiaDatabase(server_secret_key='2') - bad_client_access_key_db = VuforiaDatabase(client_access_key='3') - bad_client_secret_key_db = VuforiaDatabase(client_secret_key='4') - bad_database_name_db = VuforiaDatabase(database_name='5') + bad_server_access_key_db = CloudDatabase(server_access_key="1") + bad_server_secret_key_db = CloudDatabase(server_secret_key="2") + bad_client_access_key_db = CloudDatabase(client_access_key="3") + bad_client_secret_key_db = CloudDatabase(client_secret_key="4") + bad_database_name_db = CloudDatabase(database_name="5") server_access_key_conflict_error = ( - 'All server access keys must be unique. ' + "All server access keys must be unique. " 'There is already a database with the server access key "1".' ) server_secret_key_conflict_error = ( - 'All server secret keys must be unique. ' + "All server secret keys must be unique. " 'There is already a database with the server secret key "2".' ) client_access_key_conflict_error = ( - 'All client access keys must be unique. ' + "All client access keys must be unique. " 'There is already a database with the client access key "3".' ) client_secret_key_conflict_error = ( - 'All client secret keys must be unique. ' + "All client secret keys must be unique. " 'There is already a database with the client secret key "4".' ) database_name_conflict_error = ( - 'All names must be unique. ' + "All names must be unique. " 'There is already a database with the name "5".' ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) for bad_database, expected_message in ( (bad_server_access_key_db, server_access_key_conflict_error), (bad_server_secret_key_db, server_secret_key_conflict_error), @@ -528,7 +675,380 @@ def test_duplicate_keys(self) -> None: (bad_client_secret_key_db, client_secret_key_conflict_error), (bad_database_name_db, database_name_conflict_error), ): - with pytest.raises(ValueError) as exc: - mock.add_database(database=bad_database) + with pytest.raises( + expected_exception=ValueError, + match=expected_message + "$", + ): + mock.add_cloud_database(cloud_database=bad_database) + + @staticmethod + def test_duplicate_vumark_keys() -> None: + """ + It is not possible to have multiple databases with matching + keys, including VuMark databases. + """ + database = VuMarkDatabase( + server_access_key="1", + server_secret_key="2", + database_name="3", + ) + + bad_server_access_key_db = VuMarkDatabase(server_access_key="1") + bad_server_secret_key_db = VuMarkDatabase(server_secret_key="2") + bad_database_name_db = VuMarkDatabase(database_name="3") + + server_access_key_conflict_error = ( + "All server access keys must be unique. " + 'There is already a database with the server access key "1".' + ) + server_secret_key_conflict_error = ( + "All server secret keys must be unique. " + 'There is already a database with the server secret key "2".' + ) + database_name_conflict_error = ( + "All names must be unique. " + 'There is already a database with the name "3".' + ) + + with MockVWS() as mock: + mock.add_vumark_database(vumark_database=database) + for bad_database, expected_message in ( + (bad_server_access_key_db, server_access_key_conflict_error), + (bad_server_secret_key_db, server_secret_key_conflict_error), + (bad_database_name_db, database_name_conflict_error), + ): + with pytest.raises( + expected_exception=ValueError, + match=expected_message + "$", + ): + mock.add_vumark_database(vumark_database=bad_database) + + +class TestQueryImageMatchers: + """Tests for query image matchers.""" + + @staticmethod + def test_exact_match(high_quality_image: io.BytesIO) -> None: + """The exact matcher matches only exactly the same images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(query_match_checker=ExactMatcher()) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert len(same_image_result) == 1 + different_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert not different_image_result + + @staticmethod + def test_custom_matcher(high_quality_image: io.BytesIO) -> None: + """It is possible to use a custom matcher.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(query_match_checker=_not_exact_matcher) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert not same_image_result + different_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert len(different_image_result) == 1 + + @staticmethod + def test_structural_similarity_matcher( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + ) -> None: + """The structural similarity matcher matches similar images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS( + query_match_checker=StructuralSimilarityMatcher(), + ) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + same_image_result = cloud_reco_client.query( + image=high_quality_image, + ) + assert len(same_image_result) == 1 + similar_image_result = cloud_reco_client.query( + image=re_exported_image, + ) + assert len(similar_image_result) == 1 + + different_image_result = cloud_reco_client.query( + image=different_high_quality_image, + ) + assert not different_image_result + + +class TestDuplicatesImageMatchers: + """Tests for duplicates image matchers.""" + + @staticmethod + def test_exact_match(high_quality_image: io.BytesIO) -> None: + """The exact matcher matches only exactly the same images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(duplicate_match_checker=ExactMatcher()) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example_0", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + not_duplicate_target_id = vws_client.add_target( + name="example_2", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + vws_client.wait_for_target_processed( + target_id=not_duplicate_target_id, + ) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + @staticmethod + def test_custom_matcher(high_quality_image: io.BytesIO) -> None: + """It is possible to use a custom matcher.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) - assert str(exc.value) == expected_message + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS(duplicate_match_checker=_not_exact_matcher) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example_0", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + not_duplicate_target_id = vws_client.add_target( + name="example_2", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + vws_client.wait_for_target_processed( + target_id=not_duplicate_target_id, + ) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [not_duplicate_target_id] + + @staticmethod + def test_structural_similarity_matcher( + high_quality_image: io.BytesIO, + ) -> None: + """The structural similarity matcher matches similar images.""" + database = CloudDatabase() + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + pil_image = Image.open(fp=high_quality_image) + re_exported_image = io.BytesIO() + pil_image.save(fp=re_exported_image, format="PNG") + + with MockVWS( + duplicate_match_checker=StructuralSimilarityMatcher(), + ) as mock: + mock.add_cloud_database(cloud_database=database) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + duplicate_target_id = vws_client.add_target( + name="example_1", + width=1, + image=re_exported_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=duplicate_target_id) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [duplicate_target_id] + + +# This is in the wrong file really as it hits both the in memory mock and the +# Flask app. +@pytest.mark.usefixtures("mock_only_vuforia") +class TestDataTypes: + """Tests for sending various data types.""" + + @staticmethod + def test_text(endpoint: Endpoint) -> None: + """It is possible to send strings to VWS endpoints.""" + netloc = urlparse(url=endpoint.base_url).netloc + + if netloc == "cloudreco.vuforia.com": + pytest.skip() + + assert isinstance(endpoint.data, bytes) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=endpoint.headers, + data=endpoint.data.decode(encoding="utf-8"), + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + response = new_endpoint.send() + assert response.status_code == endpoint.successful_headers_status_code + + +class TestHttpxAlsoIntercepted: + """Tests that MockVWS also intercepts httpx requests.""" + + @staticmethod + def test_httpx_vuforia_endpoint_intercepted() -> None: + """``MockVWS`` intercepts ``httpx`` requests to Vuforia + endpoints. + """ + with MockVWS(): + response = httpx.get( + url="https://vws.vuforia.com/summary", + headers={ + "Date": rfc_1123_date(), + "Authorization": "bad_auth_token", + }, + timeout=30, + ) + assert response.status_code is not None + + @staticmethod + def test_httpx_unmocked_address_blocked() -> None: + """``MockVWS`` blocks ``httpx`` requests to non-Vuforia + addresses. + """ + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + with MockVWS(), pytest.raises(expected_exception=httpx.ConnectError): + httpx.get(url=f"http://localhost:{port}", timeout=30) + + @staticmethod + def test_httpx_real_http() -> None: + """When ``real_http=True``, ``httpx`` requests to non-Vuforia + addresses are not blocked. + """ + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + with ( + MockVWS(real_http=True), + pytest.raises(expected_exception=httpx.ConnectError), + ): + httpx.get(url=f"http://localhost:{port}", timeout=30) diff --git a/tests/mock_vws/test_respx_mock_usage.py b/tests/mock_vws/test_respx_mock_usage.py new file mode 100644 index 000000000..5db88b2c5 --- /dev/null +++ b/tests/mock_vws/test_respx_mock_usage.py @@ -0,0 +1,161 @@ +"""Tests for ``MockVWS`` intercepting ``httpx`` via synchronous ``vws`` +clients. +""" + +import io +import uuid + +import httpx +import pytest +from vws import VWS, CloudRecoService, VuMarkService +from vws.exceptions.vws_exceptions import UnknownTargetError +from vws.reports import TargetStatuses +from vws.transports import HTTPXTransport +from vws.vumark_accept import VuMarkAccept + +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.image_matchers import ExactMatcher +from mock_vws.target import VuMarkTarget + + +class TestVWS: + """Synchronous ``vws-python`` client usage through the mock via + ``httpx``. + """ + + @staticmethod + def test_response_delay_causes_httpx_timeout() -> None: + """``httpx`` timeouts are surfaced through ``VWS``.""" + database = CloudDatabase() + calls: list[float] = [] + + with MockVWS( + response_delay_seconds=5.0, + sleep_fn=calls.append, + processing_time_seconds=0, + ) as mock: + mock.add_cloud_database(cloud_database=database) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=0.1, + transport=HTTPXTransport(), + ) + with pytest.raises(expected_exception=httpx.ReadTimeout): + client.get_database_summary_report() + + assert calls == [0.1] + + @staticmethod + def test_custom_base_vws_url_with_path_prefix() -> None: + """``VWS`` works with a custom VWS base URL path prefix.""" + database = CloudDatabase() + base_vws_url = "https://vuforia.vws.example.com/prefix" + + with MockVWS(base_vws_url=base_vws_url) as mock: + mock.add_cloud_database(cloud_database=database) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + transport=HTTPXTransport(), + ) + report = client.get_database_summary_report() + database_name = report.name + + assert database_name == database.database_name + + @staticmethod + def test_add_get_and_delete_target( + image_file_success_state_low_rating: io.BytesIO, + ) -> None: + """A target life cycle works through ``VWS``.""" + database = CloudDatabase() + target_name = "async-target" + + with MockVWS(processing_time_seconds=0) as mock: + mock.add_cloud_database(cloud_database=database) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=HTTPXTransport(), + ) + target_id = client.add_target( + name=target_name, + width=1, + image=image_file_success_state_low_rating, + application_metadata=None, + active_flag=True, + ) + client.wait_for_target_processed(target_id=target_id) + target_record = client.get_target_record(target_id=target_id) + assert target_record.status == TargetStatuses.SUCCESS + assert target_record.target_record.name == target_name + + client.delete_target(target_id=target_id) + + with pytest.raises(expected_exception=UnknownTargetError): + client.get_target_record(target_id=target_id) + + +class TestCloudRecoService: + """Synchronous cloud query usage through the mock via ``httpx``.""" + + @staticmethod + def test_query_returns_match(high_quality_image: io.BytesIO) -> None: + """``CloudRecoService`` returns a match via the mock.""" + database = CloudDatabase() + + with MockVWS( + processing_time_seconds=0, + query_match_checker=ExactMatcher(), + ) as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=HTTPXTransport(), + ) + query_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + transport=HTTPXTransport(), + ) + target_id = vws_client.add_target( + name="query-target", + width=1, + image=high_quality_image, + application_metadata=None, + active_flag=True, + ) + vws_client.wait_for_target_processed(target_id=target_id) + results = query_client.query(image=high_quality_image) + assert [result.target_id for result in results] == [target_id] + + +class TestVuMarkService: + """Synchronous VuMark generation usage through the mock via + ``httpx``. + """ + + @staticmethod + def test_generate_vumark_instance_returns_png_bytes() -> None: + """``VuMarkService`` returns VuMark image bytes.""" + vumark_target = VuMarkTarget(name="test-target") + database = VuMarkDatabase(vumark_targets={vumark_target}) + + with MockVWS() as mock: + mock.add_vumark_database(vumark_database=database) + client = VuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=HTTPXTransport(), + ) + response_content = client.generate_vumark_instance( + target_id=vumark_target.target_id, + instance_id=uuid.uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert response_content.startswith(b"\x89PNG") diff --git a/tests/mock_vws/test_target_list.py b/tests/mock_vws/test_target_list.py index 7389244ec..180435ccd 100644 --- a/tests/mock_vws/test_target_list.py +++ b/tests/mock_vws/test_target_list.py @@ -1,52 +1,40 @@ -""" -Tests for the mock of the target list endpoint. -""" +"""Tests for the mock of the target list endpoint.""" import pytest from vws import VWS -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetList: - """ - Tests for the mock of the target list endpoint at `/targets`. - """ + """Tests for the mock of the target list endpoint at `/targets`.""" + @staticmethod def test_includes_targets( - self, + *, vws_client: VWS, target_id: str, ) -> None: - """ - Targets in the database are returned in the list. - """ + """Targets in the database are returned in the list.""" assert vws_client.list_targets() == [target_id] + @staticmethod def test_deleted( - self, + *, vws_client: VWS, target_id: str, ) -> None: - """ - Deleted targets are not returned in the list. - """ + """Deleted targets are not returned in the list.""" vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - assert vws_client.list_targets() == [] + assert not vws_client.list_targets() -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: - """ - The project's active state does not affect the target list. - """ + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect the target list.""" # No exception is raised. inactive_vws_client.list_targets() diff --git a/tests/mock_vws/test_target_raters.py b/tests/mock_vws/test_target_raters.py new file mode 100644 index 000000000..27dbc8cb4 --- /dev/null +++ b/tests/mock_vws/test_target_raters.py @@ -0,0 +1,76 @@ +"""Tests for target quality raters.""" + +import io + +import pytest + +from mock_vws.target_raters import ( + BrisqueTargetTrackingRater, + HardcodedTargetTrackingRater, + RandomTargetTrackingRater, +) + + +def test_random_target_tracking_rater() -> None: + """ + Test that the random target tracking rater returns a random + number. + """ + rater = RandomTargetTrackingRater() + image_content = b"content" + # We do not test that the number is truly random, but we think that if we + # try this a number of times, it is highly likely that the numbers will not + # all be the same. + ratings = [rater(image_content=image_content) for _ in range(50)] + sorted_ratings = sorted(ratings) + lowest_rating = sorted_ratings[0] + highest_rating = sorted_ratings[-1] + minimum_rating = 0 + maximum_rating = 5 + assert lowest_rating >= minimum_rating + assert highest_rating <= maximum_rating + assert lowest_rating != highest_rating + + +@pytest.mark.parametrize(argnames="rating", argvalues=range(-10, 10)) +def test_hardcoded_target_tracking_rater(rating: int) -> None: + """ + Test that the hardcoded target tracking rater returns the hardcoded + number. + """ + rater = HardcodedTargetTrackingRater(rating=rating) + image_content = b"content" + ratings = [rater(image_content=image_content) for _ in range(50)] + assert all(given_rating == rating for given_rating in ratings) + + +class TestBrisqueTargetTrackingRater: + """Tests for the BRISQUE target tracking rater.""" + + @staticmethod + def test_low_quality_image( + image_file_success_state_low_rating: io.BytesIO, + ) -> None: + """Test that a low quality image returns a low rating.""" + rater = BrisqueTargetTrackingRater() + image_content = image_file_success_state_low_rating.getvalue() + rating = rater(image_content=image_content) + assert rating == 0 + + @staticmethod + def test_high_quality_image(high_quality_image: io.BytesIO) -> None: + """Test that a high quality image returns a high rating.""" + rater = BrisqueTargetTrackingRater() + image_content = high_quality_image.getvalue() + rating = rater(image_content=image_content) + assert rating > 1 + + @staticmethod + def test_different_high_quality_image( + different_high_quality_image: io.BytesIO, + ) -> None: + """Test that a high quality image returns a high rating.""" + rater = BrisqueTargetTrackingRater() + image_content = different_high_quality_image.getvalue() + rating = rater(image_content=image_content) + assert rating > 1 diff --git a/tests/mock_vws/test_target_summary.py b/tests/mock_vws/test_target_summary.py index 4a8b255ad..6f1b7b0d7 100644 --- a/tests/mock_vws/test_target_summary.py +++ b/tests/mock_vws/test_target_summary.py @@ -1,6 +1,4 @@ -""" -Tests for the mock of the target summary endpoint. -""" +"""Tests for the mock of the target summary endpoint.""" import datetime import io @@ -8,33 +6,29 @@ from zoneinfo import ZoneInfo import pytest -from _pytest.fixtures import SubRequest from vws import VWS, CloudRecoService -from vws.exceptions.vws_exceptions import UnknownTarget +from vws.exceptions.vws_exceptions import UnknownTargetError from vws.reports import TargetStatuses -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetSummary: - """ - Tests for the target summary endpoint. - """ + """Tests for the target summary endpoint.""" - @pytest.mark.parametrize('active_flag', [True, False]) + @staticmethod + @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_target_summary( - self, + *, vws_client: VWS, - vuforia_database: VuforiaDatabase, + vuforia_database: CloudDatabase, image_file_failed_state: io.BytesIO, active_flag: bool, ) -> None: - """ - A target summary is returned. - """ + """A target summary is returned.""" name = uuid.uuid4().hex - gmt = ZoneInfo('GMT') + gmt = ZoneInfo(key="GMT") date_before_add_target = datetime.datetime.now(tz=gmt).date() target_id = vws_client.add_target( @@ -56,10 +50,10 @@ def test_target_summary( # In case the date changes while adding a target # we allow the date before and after adding the target. - assert report.upload_date in ( + assert report.upload_date in { date_before_add_target, date_after_add_target, - ) + } # While processing the tracking rating is -1. assert report.tracking_rating == -1 @@ -67,23 +61,24 @@ def test_target_summary( assert report.current_month_recos == 0 assert report.previous_month_recos == 0 + @staticmethod @pytest.mark.parametrize( - ['image_fixture_name', 'expected_status'], - [ - ('high_quality_image', TargetStatuses.SUCCESS), - ('image_file_failed_state', TargetStatuses.FAILED), + argnames=("image_fixture_name", "expected_status"), + argvalues=[ + ("high_quality_image", TargetStatuses.SUCCESS), + ("image_file_failed_state", TargetStatuses.FAILED), ], ) def test_after_processing( - self, + *, vws_client: VWS, - request: SubRequest, + request: pytest.FixtureRequest, image_fixture_name: str, expected_status: TargetStatuses, ) -> None: - """ - After processing is completed, the tracking rating is in the range of - 0 to 5. + """After processing is completed, the tracking rating is in the + range + of 0 to 5. The documentation says: @@ -95,10 +90,10 @@ def test_after_processing( It also shows that ``reco_rating`` is not provided even when the status is success. """ - image_file = request.getfixturevalue(image_fixture_name) + image_file = request.getfixturevalue(argname=image_fixture_name) target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file, active_flag=True, @@ -121,23 +116,20 @@ def test_after_processing( assert report.previous_month_recos == 0 -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestRecognitionCounts: - """ - Tests for the recognition counts in the summary. - """ + """Tests for the recognition counts in the summary.""" + @staticmethod def test_recognition( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, ) -> None: - """ - The recognition counts stay at 0 even after recognitions. - """ + """The recognition counts stay at 0 even after recognitions.""" target_id = vws_client.add_target( - name='example', + name="example", width=1, image=high_quality_image, active_flag=True, @@ -147,7 +139,7 @@ def test_recognition( vws_client.wait_for_target_processed(target_id=target_id) results = cloud_reco_client.query(image=high_quality_image) - [result] = results + (result,) = results assert result.target_id == target_id report = vws_client.get_target_summary_report(target_id=target_id) @@ -157,20 +149,14 @@ def test_recognition( assert report.previous_month_recos == 0 -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: - """ - The project's active state does not affect getting a target. - """ - with pytest.raises(UnknownTarget): + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect getting a target.""" + with pytest.raises(expected_exception=UnknownTargetError): inactive_vws_client.get_target_summary_report( target_id=uuid.uuid4().hex, ) diff --git a/tests/mock_vws/test_target_validators.py b/tests/mock_vws/test_target_validators.py new file mode 100644 index 000000000..0fc74601c --- /dev/null +++ b/tests/mock_vws/test_target_validators.py @@ -0,0 +1,86 @@ +"""Tests for target ID validators.""" + +from collections.abc import Iterable, Mapping +from functools import partial + +import pytest + +from mock_vws._services_validators import target_validators +from mock_vws._services_validators.target_validators import ( + validate_target_id_exists, +) +from mock_vws.database import CloudDatabase +from mock_vws.target import ImageTarget +from mock_vws.target_raters import HardcodedTargetTrackingRater +from tests.mock_vws.utils import make_image_file + + +def _database_with_target(*, target_id: str) -> CloudDatabase: + """Create a database containing one target with the given ID.""" + target = ImageTarget( + active_flag=True, + application_metadata=None, + image_value=make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ).getvalue(), + name="example", + processing_time_seconds=0, + target_id=target_id, + target_tracking_rater=HardcodedTargetTrackingRater(rating=5), + width=1, + ) + return CloudDatabase(targets={target}) + + +def _always_match_database( + *, + database: CloudDatabase, + request_headers: Mapping[str, str], + request_body: bytes | None, + request_method: str, + request_path: str, + databases: Iterable[CloudDatabase], +) -> CloudDatabase: + """Return the given database regardless of request details.""" + del request_headers + del request_body + del request_method + del request_path + del databases + return database + + +@pytest.mark.parametrize( + argnames=("request_path", "target_id"), + argvalues=[ + ("/targets/instances", "instances"), + ("/targets/target123/instances", "target123"), + ], +) +def test_validate_target_id_exists_uses_correct_path_segment( + *, + request_path: str, + target_id: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Validation uses the right target segment for both endpoint + shapes. + """ + database = _database_with_target(target_id=target_id) + + monkeypatch.setattr( + target=target_validators, + name="get_database_matching_server_keys", + value=partial(_always_match_database, database=database), + ) + + validate_target_id_exists( + request_path=request_path, + request_headers={}, + request_body=b"", + request_method="GET", + databases={database}, + ) diff --git a/tests/mock_vws/test_unexpected_json.py b/tests/mock_vws/test_unexpected_json.py index 6b0cd40f3..497119268 100644 --- a/tests/mock_vws/test_unexpected_json.py +++ b/tests/mock_vws/test_unexpected_json.py @@ -1,86 +1,88 @@ -""" -Tests for giving JSON data to endpoints which do not expect it. -""" +"""Tests for giving JSON data to endpoints which do not expect it.""" import json from http import HTTPStatus from urllib.parse import urlparse import pytest -import requests -from requests.structures import CaseInsensitiveDict from vws_auth_tools import authorization_header, rfc_1123_date from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import assert_vwq_failure +from tests.mock_vws.utils.too_many_requests import handle_server_errors -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUnexpectedJSON: - """ - Tests for giving JSON to endpoints which do not expect it. - """ + """Tests for giving JSON to endpoints which do not expect it.""" - def test_does_not_take_data( - self, - endpoint: Endpoint, - ) -> None: + @staticmethod + def test_does_not_take_data(endpoint: Endpoint) -> None: """ - Giving JSON to endpoints which do not take any JSON data returns error + Giving JSON to endpoints which do not take any JSON data returns + error responses. """ if ( - endpoint.prepared_request.headers.get( - 'Content-Type', + endpoint.headers.get( + "Content-Type", ) - == 'application/json' + == "application/json" ): return - content = bytes(json.dumps({'key': 'value'}), encoding='utf-8') - content_type = 'application/json' + content = json.dumps(obj={"key": "value"}).encode(encoding="utf-8") + content_type = "application/json" date = rfc_1123_date() - endpoint_headers = dict(endpoint.prepared_request.headers) - authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=str(endpoint.prepared_request.method), + method=endpoint.method, content=content, content_type=content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - headers = { - **endpoint_headers, - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, + "Content-Length": str(object=len(content)), } - endpoint.prepared_request.body = content - endpoint.prepared_request.headers = CaseInsensitiveDict(data=headers) - endpoint.prepared_request.prepare_content_length(body=content) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) - url = str(endpoint.prepared_request.url) - netloc = urlparse(url).netloc - if netloc == 'cloudreco.vuforia.com': + netloc = urlparse(url=endpoint.base_url).netloc + if netloc == "cloudreco.vuforia.com": # The multipart/formdata boundary is no longer in the given # content. - assert response.text == '' + assert not response.text assert_vwq_failure( response=response, status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, content_type=None, cache_control=None, www_authenticate=None, - connection='keep-alive', + connection="keep-alive", ) return assert response.status_code == HTTPStatus.BAD_REQUEST - assert response.text == '' - assert 'Content-Type' not in response.headers + assert not response.text + assert "Content-Type" not in response.headers diff --git a/tests/mock_vws/test_update_target.py b/tests/mock_vws/test_update_target.py index 2170dbb70..20245ba1a 100644 --- a/tests/mock_vws/test_update_target.py +++ b/tests/mock_vws/test_update_target.py @@ -1,47 +1,49 @@ -""" -Tests for the mock of the update target endpoint. -""" - -from __future__ import annotations +"""Tests for the mock of the update target endpoint.""" import base64 import io import json import uuid -from http import HTTPStatus -from typing import Any, Dict -from urllib.parse import urljoin +from http import HTTPMethod, HTTPStatus +from typing import Any, Final import pytest -import requests -from requests import Response -from requests_mock import PUT from vws import VWS -from vws.exceptions.vws_exceptions import BadImage, ProjectInactive +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + BadImageError, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectInactiveError, + TargetNameExistError, + TargetStatusNotSuccessError, +) from vws.reports import TargetStatuses -from vws_auth_tools import authorization_header, rfc_1123_date +from vws.response import Response from mock_vws._constants import ResultCodes -from mock_vws.database import VuforiaDatabase from tests.mock_vws.utils import make_image_file from tests.mock_vws.utils.assertions import ( assert_vws_failure, assert_vws_response, ) +_MAX_METADATA_BYTES: Final[int] = 1024 * 1024 - 1 + -def update_target( - vuforia_database: VuforiaDatabase, - data: Dict[str, Any], +def _update_target( + *, + vws_client: VWS, + data: dict[str, Any], target_id: str, - content_type: str = 'application/json', + content_type: str = "application/json", ) -> Response: - """ - Make a request to the endpoint to update a target. + """Make a request to the endpoint to update a target. Args: - vuforia_database: The credentials to use to connect to - Vuforia. + vws_client: The client to use to connect to Vuforia. data: The data to send, in JSON format, to the endpoint. target_id: The ID of the target to update. content_type: The `Content-Type` header to use. @@ -49,130 +51,113 @@ def update_target( Returns: The response returned by the API. """ - date = rfc_1123_date() - request_path = '/targets/' + target_id - - content = bytes(json.dumps(data), encoding='utf-8') - - authorization_string = authorization_header( - access_key=vuforia_database.server_access_key, - secret_key=vuforia_database.server_secret_key, - method=PUT, - content=content, - content_type=content_type, - date=date, - request_path=request_path, - ) - - headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type, - } - - response = requests.request( - method=PUT, - url=urljoin('https://vws.vuforia.com/', request_path), - headers=headers, + content = json.dumps(obj=data).encode(encoding="utf-8") + return vws_client.make_request( + method=HTTPMethod.PUT, data=content, + request_path=f"/targets/{target_id}", + expected_result_code=ResultCodes.SUCCESS.value, + content_type=content_type, ) - return response - -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUpdate: - """ - Tests for updating targets. - """ + """Tests for updating targets.""" + @staticmethod @pytest.mark.parametrize( - 'content_type', - [ + argnames="content_type", + argvalues=[ # This is the documented required content type: - 'application/json', + "application/json", # Other content types also work. - 'other/content_type', + "other/content_type", ], - ids=['Documented Content-Type', 'Undocumented Content-Type'], + ids=["Documented Content-Type", "Undocumented Content-Type"], ) def test_content_types( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, content_type: str, ) -> None: """ - The ``Content-Type`` header does not change the response as long as it + The ``Content-Type`` header does not change the response as long + as it is not empty. """ target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file_failed_state, active_flag=True, application_metadata=None, ) - response = update_target( - vuforia_database=vuforia_database, - data={'name': 'Adam'}, - target_id=target_id, - content_type=content_type, - ) + with pytest.raises( + expected_exception=TargetStatusNotSuccessError + ) as exc: + _update_target( + vws_client=vws_client, + data={"name": "Adam"}, + target_id=target_id, + content_type=content_type, + ) # Code is FORBIDDEN because the target is processing. assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.TARGET_STATUS_NOT_SUCCESS, ) + @staticmethod def test_empty_content_type( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ - An ``UNAUTHORIZED`` response is given if an empty ``Content-Type`` + An ``UNAUTHORIZED`` response is given if an empty ``Content- + Type`` header is given. """ target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image_file_failed_state, active_flag=True, application_metadata=None, ) - response = update_target( - vuforia_database=vuforia_database, - data={'name': 'Adam'}, - target_id=target_id, - content_type='', - ) + with pytest.raises( + expected_exception=AuthenticationFailureError + ) as exc: + _update_target( + vws_client=vws_client, + data={"name": "Adam"}, + target_id=target_id, + content_type="", + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNAUTHORIZED, result_code=ResultCodes.AUTHENTICATION_FAILURE, ) + @staticmethod def test_no_fields_given( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, ) -> None: - """ - No data fields are required. - """ + """No data fields are required.""" vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, + response = _update_target( + vws_client=vws_client, data={}, target_id=target_id, ) @@ -183,7 +168,9 @@ def test_no_fields_given( result_code=ResultCodes.SUCCESS, ) - assert response.json().keys() == {'result_code', 'transaction_id'} + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"result_code", "transaction_id"} target_details = vws_client.get_target_record(target_id=target_id) # Targets go back to processing after being updated. @@ -195,70 +182,67 @@ def test_no_fields_given( assert target_details.status == TargetStatuses.SUCCESS -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestUnexpectedData: - """ - Tests for passing data which is not allowed to the endpoint. - """ + """Tests for passing data which is not allowed to the endpoint.""" + @staticmethod def test_invalid_extra_data( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, ) -> None: """ - A `BAD_REQUEST` response is returned when unexpected data is given. + A `BAD_REQUEST` response is returned when unexpected data is + given. """ vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'extra_thing': 1}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"extra_thing": 1}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestWidth: - """ - Tests for the target width field. - """ + """Tests for the target width field.""" + @staticmethod @pytest.mark.parametrize( - 'width', - [-1, '10', None, 0], - ids=['Negative', 'Wrong Type', 'None', 'Zero'], + argnames="width", + argvalues=[-1, "10", None, 0], + ids=["Negative", "Wrong Type", "None", "Zero"], ) def test_width_invalid( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, - width: Any, + width: int | str | None, target_id: str, ) -> None: - """ - The width must be a number greater than zero. - """ + """The width must be a number greater than zero.""" vws_client.wait_for_target_processed(target_id=target_id) target_details = vws_client.get_target_record(target_id=target_id) original_width = target_details.target_record.width - response = update_target( - vuforia_database=vuforia_database, - data={'width': width}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"width": width}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) @@ -266,10 +250,9 @@ def test_width_invalid( target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.width == original_width - def test_width_valid(self, vws_client: VWS, target_id: str) -> None: - """ - Positive numbers are valid widths. - """ + @staticmethod + def test_width_valid(*, vws_client: VWS, target_id: str) -> None: + """Positive numbers are valid widths.""" vws_client.wait_for_target_processed(target_id=target_id) width = 0.01 @@ -278,24 +261,27 @@ def test_width_valid(self, vws_client: VWS, target_id: str) -> None: assert target_details.target_record.width == width -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestActiveFlag: - """ - Tests for the active flag parameter. - """ + """Tests for the active flag parameter.""" - @pytest.mark.parametrize('initial_active_flag', [True, False]) - @pytest.mark.parametrize('desired_active_flag', [True, False]) + @staticmethod + @pytest.mark.parametrize( + argnames="initial_active_flag", + argvalues=[True, False], + ) + @pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=[True, False], + ) def test_active_flag( - self, + *, vws_client: VWS, image_file_success_state_low_rating: io.BytesIO, initial_active_flag: bool, desired_active_flag: bool, ) -> None: - """ - Setting the active flag to a Boolean value changes it. - """ + """Setting the active flag to a Boolean value changes it.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -313,195 +299,188 @@ def test_active_flag( target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.active_flag == desired_active_flag - @pytest.mark.parametrize('desired_active_flag', ['string', None]) + @staticmethod + @pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=["string", None], + ) def test_invalid( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, desired_active_flag: str | None, ) -> None: """ - Values which are not Boolean values are not valid active flags. + Values which are not Boolean values are not valid active + flags. """ vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'active_flag': desired_active_flag}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"active_flag": desired_active_flag}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestApplicationMetadata: - """ - Tests for the application metadata parameter. - """ - - _MAX_METADATA_BYTES = 1024 * 1024 - 1 + """Tests for the application metadata parameter.""" + @staticmethod @pytest.mark.parametrize( - 'metadata', - [ - b'a', - b'a' * _MAX_METADATA_BYTES, + argnames="metadata", + argvalues=[ + b"a", + b"a" * _MAX_METADATA_BYTES, ], - ids=['Short', 'Max length'], + ids=["Short", "Max length"], ) def test_base64_encoded( - self, + *, target_id: str, metadata: bytes, vws_client: VWS, ) -> None: - """ - A base64 encoded string is valid application metadata. - """ - metadata_encoded = base64.b64encode(metadata).decode('ascii') + """A base64 encoded string is valid application metadata.""" + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.update_target( target_id=target_id, application_metadata=metadata_encoded, ) - @pytest.mark.parametrize('invalid_metadata', [1, None]) + @staticmethod + @pytest.mark.parametrize(argnames="invalid_metadata", argvalues=[1, None]) def test_invalid_type( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, invalid_metadata: int | None, ) -> None: - """ - Non-string values cannot be given as valid application metadata. - """ + """Non-string values cannot be given as valid application metadata.""" vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': invalid_metadata}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"application_metadata": invalid_metadata}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, not_base64_encoded_processable: str, ) -> None: """ - Some strings which are not valid base64 encoded strings are allowed as + Some strings which are not valid base64 encoded strings are + allowed as application metadata. """ vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': not_base64_encoded_processable}, + vws_client.update_target( target_id=target_id, + application_metadata=not_base64_encoded_processable, ) - assert_vws_response( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) - + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, not_base64_encoded_not_processable: str, ) -> None: """ - Some strings which are not valid base64 encoded strings are not allowed + Some strings which are not valid base64 encoded strings are not + allowed as application metadata. """ vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': not_base64_encoded_not_processable}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + vws_client.update_target( + target_id=target_id, + application_metadata=not_base64_encoded_not_processable, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) - def test_metadata_too_large( - self, - vuforia_database: VuforiaDatabase, - vws_client: VWS, - target_id: str, - ) -> None: + @staticmethod + def test_metadata_too_large(*, vws_client: VWS, target_id: str) -> None: """ - A base64 encoded string of greater than 1024 * 1024 bytes is too large + A base64 encoded string of greater than 1024 * 1024 bytes is too + large for application metadata. """ - metadata = b'a' * (self._MAX_METADATA_BYTES + 1) - metadata_encoded = base64.b64encode(metadata).decode('ascii') + metadata = b"a" * (_MAX_METADATA_BYTES + 1) + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'application_metadata': metadata_encoded}, - target_id=target_id, - ) + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: + vws_client.update_target( + target_id=target_id, + application_metadata=metadata_encoded, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.METADATA_TOO_LARGE, ) -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestTargetName: - """ - Tests for the target name field. - """ + """Tests for the target name field.""" _MAX_CHAR_VALUE = 65535 _MAX_NAME_LENGTH = 64 + @staticmethod @pytest.mark.parametrize( - 'name', - [ - 'á', + argnames="name", + argvalues=[ + "á", # We test just below the max character value. # This is because targets with the max character value in their # names get stuck in the processing stage. chr(_MAX_CHAR_VALUE - 2), - 'a' * _MAX_NAME_LENGTH, + "a" * _MAX_NAME_LENGTH, ], - ids=['Short name', 'Max char value', 'Long name'], + ids=["Short name", "Max char value", "Long name"], ) def test_name_valid( - self, + *, name: str, target_id: str, vws_client: VWS, ) -> None: - """ - A target's name must be a string of length 0 < N < 65. + """A target's name must be a string of length 0 < N < 65. We test characters out of range in another test as that gives a different error. @@ -511,13 +490,14 @@ def test_name_valid( target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.name == name + @staticmethod @pytest.mark.parametrize( - 'name,status_code,result_code', - [ + argnames=("name", "status_code", "result_code"), + argvalues=[ (1, HTTPStatus.BAD_REQUEST, ResultCodes.FAIL), - ('', HTTPStatus.BAD_REQUEST, ResultCodes.FAIL), + ("", HTTPStatus.BAD_REQUEST, ResultCodes.FAIL), ( - 'a' * (_MAX_NAME_LENGTH + 1), + "a" * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST, ResultCodes.FAIL, ), @@ -534,51 +514,47 @@ def test_name_valid( ), ], ids=[ - 'Wrong Type', - 'Empty', - 'Too Long', - 'None', - 'Bad char', - 'Bad char too long', + "Wrong Type", + "Empty", + "Too Long", + "None", + "Bad char", + "Bad char too long", ], ) def test_name_invalid( - self, - name: str, + *, + name: str | int | None, target_id: str, - vuforia_database: VuforiaDatabase, vws_client: VWS, status_code: int, result_code: ResultCodes, ) -> None: - """ - A target's name must be a string of length 0 < N < 65. - """ + """A target's name must be a string of length 0 < N < 65.""" vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'name': name}, - target_id=target_id, - ) + with pytest.raises(expected_exception=VWSError) as exc: + _update_target( + vws_client=vws_client, + data={"name": name}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=status_code, result_code=result_code, ) + @staticmethod def test_existing_target_name( - self, + *, image_file_success_state_low_rating: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - Only one target can have a given name. - """ - first_target_name = 'example_name' - second_target_name = 'another_example_name' + """Only one target can have a given name.""" + first_target_name = "example_name" + second_target_name = "another_example_name" first_target_id = vws_client.add_target( name=first_target_name, @@ -599,28 +575,26 @@ def test_existing_target_name( vws_client.wait_for_target_processed(target_id=first_target_id) vws_client.wait_for_target_processed(target_id=second_target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'name': first_target_name}, - target_id=second_target_id, - ) + with pytest.raises(expected_exception=TargetNameExistError) as exc: + vws_client.update_target( + target_id=second_target_id, + name=first_target_name, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.FORBIDDEN, result_code=ResultCodes.TARGET_NAME_EXIST, ) + @staticmethod def test_same_name_given( - self, + *, image_file_success_state_low_rating: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - Updating a target with its own name does not give an error. - """ - name = 'example' + """Updating a target with its own name does not give an error.""" + name = "example" target_id = vws_client.add_target( name=name, @@ -631,116 +605,98 @@ def test_same_name_given( ) vws_client.wait_for_target_processed(target_id=target_id) - - response = update_target( - vuforia_database=vuforia_database, - data={'name': name}, - target_id=target_id, - ) - - assert_vws_failure( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) - + vws_client.update_target(target_id=target_id, name=name) target_details = vws_client.get_target_record(target_id=target_id) assert target_details.target_record.name == name -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestImage: - """ - Tests for the image parameter. + """Tests for the image parameter. The specification for images is documented at https://library.vuforia.com/features/images/image-targets.html. """ + @staticmethod def test_image_valid( - self, - vuforia_database: VuforiaDatabase, + *, image_files_failed_state: io.BytesIO, target_id: str, vws_client: VWS, ) -> None: """ - JPEG and PNG files in the RGB and greyscale color spaces are allowed. + JPEG and PNG files in the RGB and greyscale color spaces are + allowed. """ - image_file = image_files_failed_state - image_data = image_file.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, + vws_client.update_target( target_id=target_id, + image=image_files_failed_state, ) - assert_vws_response( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) - + @staticmethod def test_bad_image_format_or_color_space( - self, + *, bad_image_file: io.BytesIO, target_id: str, vws_client: VWS, ) -> None: """ - A `BAD_IMAGE` response is returned if an image which is not a JPEG - or PNG file is given, or if the given image is not in the greyscale or - RGB color space. + A `BAD_IMAGE` response is returned if an image which is not a + JPEG or + PNG file is given, or if the given image is not in the greyscale or + RGB + color space. """ vws_client.wait_for_target_processed(target_id=target_id) - with pytest.raises(BadImage) as exc: + with pytest.raises(expected_exception=BadImageError) as exc: vws_client.update_target(target_id=target_id, image=bad_image_file) status_code = exc.value.response.status_code assert status_code == HTTPStatus.UNPROCESSABLE_ENTITY + @staticmethod def test_corrupted( - self, + *, vws_client: VWS, corrupted_image_file: io.BytesIO, target_id: str, ) -> None: - """ - No error is returned when the given image is corrupted. - """ + """An error is returned when the given image is corrupted.""" vws_client.wait_for_target_processed(target_id=target_id) - vws_client.update_target( - target_id=target_id, - image=corrupted_image_file, + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=corrupted_image_file, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.BAD_IMAGE, ) - def test_image_too_large( - self, - vuforia_database: VuforiaDatabase, - target_id: str, - vws_client: VWS, - ) -> None: + @staticmethod + def test_image_too_large(*, target_id: str, vws_client: VWS) -> None: """ - An `ImageTooLarge` result is returned if the image is above a certain - threshold. + An `ImageTooLargeError` result is returned if the image is above + a + certain threshold. """ max_bytes = 2.3 * 1024 * 1024 width = height = 886 png_not_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) vws_client.wait_for_target_processed(target_id=target_id) - image_data = png_not_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_not_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -750,31 +706,20 @@ def test_image_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, - target_id=target_id, - ) - - assert_vws_response( - response=response, - status_code=HTTPStatus.OK, - result_code=ResultCodes.SUCCESS, - ) + vws_client.update_target(target_id=target_id, image=png_not_too_large) vws_client.wait_for_target_processed(target_id=target_id) width = width + 1 height = height + 1 png_too_large = make_image_file( - file_format='PNG', - color_space='RGB', + file_format="PNG", + color_space="RGB", width=width, height=height, ) - image_data = png_too_large.read() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = png_too_large.getvalue() image_content_size = len(image_data) # We check that the image we created is just slightly smaller than the # maximum file size. @@ -784,141 +729,134 @@ def test_image_too_large( assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, - target_id=target_id, - ) + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.update_target(target_id=target_id, image=png_too_large) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.IMAGE_TOO_LARGE, ) + @staticmethod def test_not_base64_encoded_processable( - self, - vuforia_database: VuforiaDatabase, + *, + vws_client: VWS, target_id: str, not_base64_encoded_processable: str, - vws_client: VWS, ) -> None: - """ - Some strings which are not valid base64 encoded strings are allowed as - an image without getting a "Fail" response. - This is because Vuforia treats them as valid base64, but then not a - valid image. + """Some strings which are not valid base64 encoded strings are + allowed + as an image without getting a "Fail" response. + + This is because Vuforia treats them as valid base64, but then + not a valid image. """ vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'image': not_base64_encoded_processable}, - target_id=target_id, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + _update_target( + vws_client=vws_client, + data={"image": not_base64_encoded_processable}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod def test_not_base64_encoded_not_processable( - self, - vuforia_database: VuforiaDatabase, + *, vws_client: VWS, target_id: str, not_base64_encoded_not_processable: str, ) -> None: """ Some strings which are not valid base64 encoded strings are not - processable by Vuforia, and then when given as an image Vuforia returns + processable by Vuforia, and then when given as an image Vuforia + returns a "Fail" response. """ vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'image': not_base64_encoded_not_processable}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"image": not_base64_encoded_not_processable}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.FAIL, ) - def test_not_image( - self, - vuforia_database: VuforiaDatabase, - target_id: str, - vws_client: VWS, - ) -> None: + @staticmethod + def test_not_image(*, target_id: str, vws_client: VWS) -> None: """ - If the given image is not an image file then a `BadImage` result is - returned. + If the given image is not an image file then a `BadImageError` + result + is returned. """ - not_image_data = b'not_image_data' - image_data_encoded = base64.b64encode(not_image_data).decode('ascii') - vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'image': image_data_encoded}, - target_id=target_id, - ) + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=io.BytesIO(initial_bytes=b"not_image_data"), + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.UNPROCESSABLE_ENTITY, result_code=ResultCodes.BAD_IMAGE, ) - @pytest.mark.parametrize('invalid_type_image', [1, None]) + @staticmethod + @pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], + ) def test_invalid_type( - self, + *, invalid_type_image: int | None, target_id: str, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - If the given image is not a string, a `Fail` result is returned. - """ + """If the given image is not a string, a `Fail` result is returned.""" vws_client.wait_for_target_processed(target_id=target_id) - response = update_target( - vuforia_database=vuforia_database, - data={'image': invalid_type_image}, - target_id=target_id, - ) + with pytest.raises(expected_exception=FailError) as exc: + _update_target( + vws_client=vws_client, + data={"image": invalid_type_image}, + target_id=target_id, + ) assert_vws_failure( - response=response, + response=exc.value.response, status_code=HTTPStatus.BAD_REQUEST, result_code=ResultCodes.FAIL, ) + @staticmethod def test_rating_can_change( - self, + *, image_file_success_state_low_rating: io.BytesIO, high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """ - If the target is updated with an image of different quality, the + """If the target is updated with an image of different quality, the tracking rating can change. - "quality" refers to Vuforia's internal rating system. - The mock randomly assigns a quality and makes sure that the new quality + "quality" refers to Vuforia's internal rating system. The mock + randomly assigns a quality and makes sure that the new quality is different to the old quality. """ - good_image = high_quality_image.read() - good_image_data_encoded = base64.b64encode(good_image).decode('ascii') - target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -935,11 +873,7 @@ def test_rating_can_change( original_tracking_rating = target_details.target_record.tracking_rating assert original_tracking_rating in range(6) - update_target( - vuforia_database=vuforia_database, - data={'image': good_image_data_encoded}, - target_id=target_id, - ) + vws_client.update_target(target_id=target_id, image=high_quality_image) vws_client.wait_for_target_processed(target_id=target_id) target_details = vws_client.get_target_record(target_id=target_id) @@ -951,18 +885,15 @@ def test_rating_can_change( assert original_tracking_rating != new_tracking_rating -@pytest.mark.usefixtures('verify_mock_vuforia') +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInactiveProject: - """ - Tests for inactive projects. - """ + """Tests for inactive projects.""" - def test_inactive_project( - self, - inactive_vws_client: VWS, - ) -> None: + @staticmethod + def test_inactive_project(inactive_vws_client: VWS) -> None: """ - If the project is inactive, a FORBIDDEN response is returned. + If the project is inactive, a FORBIDDEN response is + returned. """ - with pytest.raises(ProjectInactive): + with pytest.raises(expected_exception=ProjectInactiveError): inactive_vws_client.update_target(target_id=uuid.uuid4().hex) diff --git a/tests/mock_vws/test_vumark_generation_api.py b/tests/mock_vws/test_vumark_generation_api.py new file mode 100644 index 000000000..58de8abb5 --- /dev/null +++ b/tests/mock_vws/test_vumark_generation_api.py @@ -0,0 +1,370 @@ +"""Tests for the VuMark generation web API.""" + +import json +from http import HTTPMethod, HTTPStatus +from uuid import uuid4 + +import pytest +import requests +from beartype import beartype +from vws import VWS, VuMarkService +from vws.exceptions.vws_exceptions import ( + InvalidInstanceIdError, + InvalidTargetTypeError, + TargetStatusNotSuccessError, +) +from vws.vumark_accept import VuMarkAccept +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws._constants import ResultCodes +from mock_vws.database import CloudDatabase +from tests.mock_vws.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, + VuMarkCloudDatabase, +) +from tests.mock_vws.utils import make_image_file + +_VWS_HOST = "https://vws.vuforia.com" +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_PDF_SIGNATURE = b"%PDF" +_SVG_START = b"<" + + +@beartype +def _make_vumark_service( + *, + server_access_key: str, + server_secret_key: str, +) -> VuMarkService: + """Return a VuMark service client.""" + return VuMarkService( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + + +@beartype +def _make_vumark_request( + *, + server_access_key: str, + server_secret_key: str, + target_id: str, + instance_id: str, + accept: str, +) -> requests.Response: + """Send a VuMark instance generation request and return the + response. + """ + request_path = f"/targets/{target_id}/instances" + content_type = "application/json" + content = json.dumps(obj={"instance_id": instance_id}).encode( + encoding="utf-8" + ) + date = rfc_1123_date() + authorization_string = authorization_header( + access_key=server_access_key, + secret_key=server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + return requests.post( + url=_VWS_HOST + request_path, + headers={ + "Accept": accept, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + }, + data=content, + timeout=30, + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestGenerateInstance: + """Tests for the VuMark instance generation endpoint.""" + + @pytest.mark.parametrize( + argnames=("accept", "expected_signature"), + argvalues=[ + pytest.param(VuMarkAccept.PNG, _PNG_SIGNATURE, id="png"), + pytest.param( + VuMarkAccept.SVG, + _SVG_START, + id="svg", + ), + pytest.param( + VuMarkAccept.PDF, + _PDF_SIGNATURE, + id="pdf", + ), + ], + ) + @staticmethod + def test_generate_instance_format( + *, + accept: VuMarkAccept, + expected_signature: bytes, + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """A VuMark instance can be generated in the requested format.""" + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=accept, + ) + + assert vumark_bytes.strip().startswith(expected_signature) + assert len(vumark_bytes) > len(expected_signature) + + @pytest.mark.parametrize( + argnames=("accept", "expected_content_type"), + argvalues=[ + pytest.param("image/png", "image/png", id="png"), + pytest.param("image/svg+xml", "image/svg+xml", id="svg"), + pytest.param("application/pdf", "application/pdf", id="pdf"), + ], + ) + @staticmethod + def test_generate_instance_content_type_header( + *, + accept: str, + expected_content_type: str, + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """VuMark image responses include the expected content type.""" + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=accept, + ) + + assert response.status_code == HTTPStatus.OK + assert ( + response.headers["Content-Type"].split(sep=";")[0] + == expected_content_type + ) + + @staticmethod + def test_invalid_accept_header( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """An unsupported Accept header returns an error.""" + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept="text/plain", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_ACCEPT_HEADER.value + ) + + @staticmethod + def test_empty_instance_id( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """An empty instance_id returns InvalidInstanceId.""" + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id="", + accept=VuMarkAccept.PNG, + ) + + assert ( + exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + ) + response_json = json.loads(s=exc.value.response.text) + assert ( + response_json["result_code"] + == ResultCodes.INVALID_INSTANCE_ID.value + ) + + @staticmethod + def test_unknown_target( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """An unknown target_id returns UnknownTarget.""" + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=uuid4().hex, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + response_json = response.json() + assert response_json["result_code"] == ResultCodes.UNKNOWN_TARGET.value + + @staticmethod + def test_non_vumark_database( + vuforia_database: CloudDatabase, + ) -> None: + """Generating a VuMark instance for a target in a non-VuMark + database returns InvalidTargetType. + """ + server_access_key = vuforia_database.server_access_key + server_secret_key = vuforia_database.server_secret_key + vws_client = VWS( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + vumark_client = _make_vumark_service( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + image = make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ) + target_id = vws_client.add_target( + name="test", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: + vumark_client.generate_vumark_instance( + target_id=target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert ( + exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + ) + response_json = json.loads(s=exc.value.response.text) + assert ( + response_json["result_code"] + == ResultCodes.INVALID_TARGET_TYPE.value + ) + + @staticmethod + def test_successful_target( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """A VuMark target that has finished processing succeeds.""" + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert vumark_bytes.strip().startswith(_PNG_SIGNATURE) + + +# VuMark targets cannot be added via the VWS API — they are configured +# through the Vuforia Target Manager portal. This means we cannot +# create a target that is perpetually in PROCESSING state against real +# Vuforia. The mock controls processing time via the +# ``processing_time_seconds`` attribute on ``VuMarkTarget``, so these +# tests are inherently mock-only. +@pytest.mark.usefixtures("mock_only_vuforia") +class TestProcessingTarget: + """Tests for VuMark generation when the target is still processing. + + These use ``mock_only_vuforia`` because there is no way to keep a + VuMark target in PROCESSING state indefinitely on real Vuforia. + """ + + @staticmethod + def test_processing_target( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """A VuMark target still processing returns + TargetStatusNotSuccess. + """ + vumark_client = _make_vumark_service( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + with pytest.raises( + expected_exception=TargetStatusNotSuccessError, + ) as exc: + vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.processing_target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + response_json = json.loads(s=exc.value.response.text) + assert ( + response_json["result_code"] + == ResultCodes.TARGET_STATUS_NOT_SUCCESS.value + ) + + @staticmethod + def test_processing_target_raw_response( + vumark_vuforia_database: VuMarkCloudDatabase, + ) -> None: + """The raw HTTP response for a processing target has the expected + status code and result code. + """ + response = _make_vumark_request( + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.processing_target_id, + instance_id=uuid4().hex, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.TARGET_STATUS_NOT_SUCCESS.value + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestInactiveDatabase: + """Tests for VuMark generation with an inactive database.""" + + @staticmethod + def test_inactive_database( + inactive_vumark_database: InactiveVuMarkCloudDatabase, + ) -> None: + """Calling the VuMark generation API with credentials for an + inactive database returns ProjectInactive. + """ + response = _make_vumark_request( + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + target_id=uuid4().hex, + instance_id=uuid4().hex, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + response_json = response.json() + assert response_json["result_code"] == ResultCodes.UNKNOWN_TARGET.value diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 2fb507616..5f48d0664 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -1,84 +1,104 @@ -""" -Utilities for tests. -""" +"""Utilities for tests.""" import io -import random +import secrets +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal +from urllib.parse import urljoin import requests +from beartype import beartype from PIL import Image +from requests.structures import CaseInsensitiveDict +from vws.response import Response from mock_vws._constants import ResultCodes +@dataclass(frozen=True, kw_only=True) class Endpoint: - """ - Details of endpoints to be called in tests. + """Details of endpoints to be called in tests. + + Args: + prepared_request: A request to make which would be successful. + successful_headers_result_code: The expected result code if the + example path is requested with the method. + successful_headers_status_code: The expected status code if the + example path is requested with the method. + access_key: The access key used in the prepared request. + secret_key: The secret key used in the prepared request. + path_url: The path of the endpoint. + base_url: The base URL of the endpoint. + + Attributes: + prepared_request: A request to make which would be successful. + successful_headers_result_code: The expected result code if the + example path is requested with the method. + successful_headers_status_code: The expected status code if the + example path is requested with the method. + access_key: The access key used in the prepared request. + secret_key: The secret key used in the prepared request. + path_url: The path of the endpoint. + base_url: The base URL of the endpoint. """ - prepared_request: requests.PreparedRequest - successful_headers_result_code: ResultCodes + base_url: str + path_url: str + method: str + headers: Mapping[str, str] + data: bytes | str + successful_headers_result_code: ResultCodes | None successful_headers_status_code: int - auth_header_content_type: str access_key: str secret_key: str - def __init__( - self, - prepared_request: requests.PreparedRequest, - successful_headers_result_code: ResultCodes, - successful_headers_status_code: int, - access_key: str, - secret_key: str, - ) -> None: - """ - Args: - prepared_request: A request to make which would be successful. - successful_headers_result_code: The expected result code if the - example path is requested with the method. - successful_headers_status_code: The expected status code if the - example path is requested with the method. - access_key: The access key used in the prepared request. - secret_key: The secret key used in the prepared request. - - Attributes: - prepared_request: A request to make which would be successful. - successful_headers_result_code: The expected result code if the - example path is requested with the method. - successful_headers_status_code: The expected status code if the - example path is requested with the method. - auth_header_content_type: The content type to use for the - `Authorization` header. - access_key: The access key used in the prepared request. - secret_key: The secret key used in the prepared request. - """ - self.prepared_request = prepared_request - self.successful_headers_status_code = successful_headers_status_code - self.successful_headers_result_code = successful_headers_result_code - headers = prepared_request.headers - content_type = headers.get('Content-Type', '') - content_type = content_type.split(';')[0] - assert isinstance(content_type, str) - self.auth_header_content_type: str = content_type - self.access_key = access_key - self.secret_key = secret_key + @beartype + def send(self) -> Response: + """Send the request.""" + request = requests.Request( + method=self.method, + url=urljoin(base=self.base_url, url=self.path_url), + headers=self.headers, + data=self.data, + ) + prepared_request = request.prepare() + prepared_request.headers = CaseInsensitiveDict(data=self.headers) + session = requests.Session() + requests_response = session.send(request=prepared_request) + return Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + @property + def auth_header_content_type(self) -> str: + """The content type to use for the `Authorization` header.""" + full_content_type = dict(self.headers).get("Content-Type", "") + return full_content_type.split(sep=";")[0] + +@beartype def make_image_file( + *, file_format: str, - color_space: str, + color_space: Literal["RGB", "CMYK"], width: int, height: int, ) -> io.BytesIO: - """ - Return an image file in the given format and color space. + """Return an image file in the given format and color space. The image file is filled with randomly colored pixels. Args: file_format: See https://pillow.readthedocs.io/en/3.1.x/handbook/image-file-formats.html - color_space: One of "L", "RGB", or "CMYK". "L" means greyscale. + color_space: One of "RGB", or "CMYK". width: The width, in pixels of the image. height: The width, in pixels of the image. @@ -86,15 +106,17 @@ def make_image_file( An image file in the given format and color space. """ image_buffer = io.BytesIO() - image = Image.new(color_space, (width, height)) - # If this assertion ever fails, see - # https://github.com/VWS-Python/vws-test-fixtures for what to do. - assert color_space != 'L' - reds = random.choices(population=range(0, 255), k=width * height) - greens = random.choices(population=range(0, 255), k=width * height) - blues = random.choices(population=range(0, 255), k=width * height) - pixels = list(zip(reds, greens, blues)) - image.putdata(pixels) - image.save(image_buffer, file_format) + image = Image.new(mode=color_space, size=(width, height)) + for row_index in range(height): + for column_index in range(width): + red = secrets.choice(seq=range(255)) + green = secrets.choice(seq=range(255)) + blue = secrets.choice(seq=range(255)) + image.putpixel( + xy=(column_index, row_index), + value=(red, green, blue), + ) + + image.save(fp=image_buffer, format=file_format) image_buffer.seek(0) return image_buffer diff --git a/tests/mock_vws/utils/assertions.py b/tests/mock_vws/utils/assertions.py index c0113522e..f0d132329 100644 --- a/tests/mock_vws/utils/assertions.py +++ b/tests/mock_vws/utils/assertions.py @@ -1,8 +1,4 @@ -""" -Assertion helpers. -""" - -from __future__ import annotations +"""Assertion helpers.""" import copy import datetime @@ -12,18 +8,20 @@ from string import hexdigits from zoneinfo import ZoneInfo -from requests import Response +from beartype import beartype +from vws.response import Response from mock_vws._constants import ResultCodes +@beartype def assert_vws_failure( + *, response: Response, status_code: int, result_code: ResultCodes, ) -> None: - """ - Assert that a VWS failure response is as expected. + """Assert that a VWS failure response is as expected. Args: response: The response returned by a request to VWS. @@ -34,7 +32,10 @@ def assert_vws_failure( AssertionError: The response is not in the expected VWS error format for the given codes. """ - assert response.json().keys() == {'transaction_id', 'result_code'} + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } assert_vws_response( response=response, status_code=status_code, @@ -42,10 +43,13 @@ def assert_vws_failure( ) -def assert_valid_date_header(response: Response) -> None: - """ - Assert that a response includes a `Date` header which is within two minutes - of "now". +@beartype +def assert_valid_date_header( + *, + response: Response, +) -> None: + """Assert that a response includes a `Date` header which is within two + minutes of "now". Args: response: The response returned by a request to a Vuforia service. @@ -54,11 +58,11 @@ def assert_valid_date_header(response: Response) -> None: AssertionError: The response does not include a `Date` header which is within one minute of "now". """ - date_response = response.headers['Date'] - date_from_response = email.utils.parsedate(date_response) + date_response = response.headers["Date"] + date_from_response = email.utils.parsedate(data=date_response) assert date_from_response is not None year, month, day, hour, minute, second, _, _, _ = date_from_response - gmt = ZoneInfo('GMT') + gmt = ZoneInfo(key="GMT") datetime_from_response = datetime.datetime( year=year, month=month, @@ -73,9 +77,12 @@ def assert_valid_date_header(response: Response) -> None: assert time_difference < datetime.timedelta(minutes=2) -def assert_valid_transaction_id(response: Response) -> None: - """ - Assert that a response includes a valid transaction ID. +@beartype +def assert_valid_transaction_id( + *, + response: Response, +) -> None: + """Assert that a response includes a valid transaction ID. Args: response: The response returned by a request to a Vuforia service. @@ -83,14 +90,15 @@ def assert_valid_transaction_id(response: Response) -> None: Raises: AssertionError: The response does not include a valid transaction ID. """ - transaction_id = response.json()['transaction_id'] - assert len(transaction_id) == 32 + transaction_id = json.loads(s=response.text)["transaction_id"] + expected_transaction_id_length = 32 + assert len(transaction_id) == expected_transaction_id_length assert all(char in hexdigits for char in transaction_id) -def assert_json_separators(response: Response) -> None: - """ - Assert that a JSON response is formatted correctly. +@beartype +def assert_json_separators(*, response: Response) -> None: + """Assert that a JSON response is formatted correctly. Args: response: The response returned by a request to a Vuforia service. @@ -99,20 +107,21 @@ def assert_json_separators(response: Response) -> None: AssertionError: The response JSON is not formatted correctly. """ assert response.text == json.dumps( - obj=response.json(), - separators=(',', ':'), + obj=json.loads(s=response.text), + separators=(",", ":"), ) +@beartype def assert_vws_response( + *, response: Response, status_code: int, result_code: ResultCodes, ) -> None: - """ - Assert that a VWS response is as expected, at least in part. + """Assert that a VWS response is as expected, at least in part. - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Interperete-VWS-API-Result-Codes + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes implies that the expected status code can be worked out from the result code. However, this is not the case as the real results differ from the documentation. @@ -129,28 +138,37 @@ def assert_vws_response( given codes. """ assert response.status_code == status_code - response_result_code = response.json()['result_code'] + response_result_code = json.loads(s=response.text)["result_code"] assert response_result_code == result_code.value response_header_keys = { - 'Connection', - 'Content-Length', - 'Content-Type', - 'Date', - 'Server', + "connection", + "content-length", + "content-type", + "date", + "server", + "strict-transport-security", + "x-aws-region", + "x-content-type-options", + "x-envoy-upstream-service-time", } - assert response.headers.keys() == response_header_keys - assert response.headers['Connection'] == 'keep-alive' - assert response.headers['Content-Length'] == str(len(response.text)) - assert response.headers['Content-Type'] == 'application/json' - assert response.headers['Server'] == 'nginx' + assert {str.lower(key) for key in response.headers} == response_header_keys + assert response.headers["Content-Length"] == str(object=len(response.text)) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["server"] == "envoy" + assert response.headers["x-content-type-options"] == "nosniff" + assert "-" in response.headers["x-aws-region"] + assert response.headers["strict-transport-security"] == "max-age=31536000" + assert int(response.headers["x-envoy-upstream-service-time"]) > 1 + assert response.headers["Connection"] == "keep-alive" assert_json_separators(response=response) assert_valid_transaction_id(response=response) assert_valid_date_header(response=response) -def assert_query_success(response: Response) -> None: - """ - Assert that the given response is a success response for performing an +@beartype +def assert_query_success(*, response: Response) -> None: + """Assert that the given response is a success response for performing + an image recognition query. Raises: @@ -158,36 +176,41 @@ def assert_query_success(response: Response) -> None: for performing an image recognition query. """ assert response.status_code == HTTPStatus.OK - assert response.json().keys() == {'result_code', 'results', 'query_id'} + assert json.loads(s=response.text).keys() == { + "result_code", + "results", + "query_id", + } - query_id = response.json()['query_id'] - assert len(query_id) == 32 + query_id = json.loads(s=response.text)["query_id"] + expected_query_id_length = 32 + assert len(query_id) == expected_query_id_length assert all(char in hexdigits for char in query_id) - assert response.json()['result_code'] == 'Success' + assert json.loads(s=response.text)["result_code"] == "Success" assert_valid_date_header(response=response) - copied_response_headers = dict(copy.deepcopy(response.headers)) - copied_response_headers.pop('Date') + copied_response_headers = response.headers.copy() + copied_response_headers.pop("Date") # In the mock, all responses have the ``Content-Encoding`` ``gzip``. # In the real Vuforia, some do and some do not. # We are not sure why. - content_encoding = copied_response_headers.pop('Content-Encoding', None) - assert content_encoding in (None, 'gzip') + content_encoding = copied_response_headers.pop("Content-Encoding", None) + assert content_encoding in {None, "gzip"} expected_response_header_not_chunked = { - 'Connection': 'keep-alive', - 'Content-Length': str(response.raw.tell()), - 'Content-Type': 'application/json', - 'Server': 'nginx', + "Connection": "keep-alive", + "Content-Length": str(object=response.tell_position), + "Content-Type": "application/json", + "Server": "nginx", } # The mock does not send chunked responses. expected_response_header_chunked = { - 'Connection': 'keep-alive', - 'Content-Type': 'application/json', - 'Server': 'nginx', - 'transfer-encoding': 'chunked', + "Connection": "keep-alive", + "Content-Type": "application/json", + "Server": "nginx", + "transfer-encoding": "chunked", } assert copied_response_headers in ( @@ -196,7 +219,9 @@ def assert_query_success(response: Response) -> None: ) +@beartype def assert_vwq_failure( + *, response: Response, status_code: int, content_type: str | None, @@ -204,8 +229,7 @@ def assert_vwq_failure( www_authenticate: str | None, connection: str, ) -> None: - """ - Assert that a VWQ failure response is as expected. + """Assert that a VWQ failure response is as expected. Args: response: The response returned by a request to VWQ. @@ -221,41 +245,43 @@ def assert_vwq_failure( """ assert response.status_code == status_code response_header_keys = { - 'Connection', - 'Content-Length', - 'Date', - 'Server', + "Connection", + "Content-Length", + "Date", + "Server", } if cache_control is not None: - response_header_keys.add('Cache-Control') - assert response.headers['Cache-Control'] == cache_control + response_header_keys.add("Cache-Control") + assert response.headers["Cache-Control"] == cache_control if content_type is not None: - response_header_keys.add('Content-Type') - assert response.headers['Content-Type'] == content_type + response_header_keys.add("Content-Type") + assert response.headers["Content-Type"] == content_type if www_authenticate is not None: - response_header_keys.add('WWW-Authenticate') - assert response.headers['WWW-Authenticate'] == www_authenticate + response_header_keys.add("WWW-Authenticate") + assert response.headers["WWW-Authenticate"] == www_authenticate # Sometimes the "transfer-encoding" is given. # It is not given by the mock. - response_header_keys_chunked = copy.copy(response_header_keys) - response_header_keys_chunked.remove('Content-Length') - response_header_keys_chunked.add('transfer-encoding') + response_header_keys_chunked = copy.copy(x=response_header_keys) + response_header_keys_chunked.remove("Content-Length") + response_header_keys_chunked.add("transfer-encoding") assert response.headers.keys() in ( response_header_keys, response_header_keys_chunked, ) - assert response.headers.get('transfer-encoding', 'chunked') == 'chunked' - assert response.headers['Connection'] == connection - if 'Content-Length' in response.headers: # pragma: no cover - assert response.headers['Content-Length'] == str(len(response.text)) + assert response.headers.get("transfer-encoding", "chunked") == "chunked" + assert response.headers["Connection"] == connection + if "Content-Length" in response.headers: # pragma: no cover + assert response.headers["Content-Length"] == str( + object=len(response.text) + ) # In some tests we see that sometimes there is no Content-Length header # here. else: # pragma: no cover pass assert_valid_date_header(response=response) - assert response.headers['Server'] == 'nginx' + assert response.headers["Server"] == "nginx" diff --git a/tests/mock_vws/utils/retries.py b/tests/mock_vws/utils/retries.py new file mode 100644 index 000000000..02e980e0b --- /dev/null +++ b/tests/mock_vws/utils/retries.py @@ -0,0 +1,20 @@ +"""Helpers for retrying requests to VWS.""" + +from tenacity import retry +from tenacity.retry import retry_if_exception_type +from tenacity.wait import wait_fixed +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import ( + TooManyRequestsError, +) + +RETRY_EXCEPTIONS = (TooManyRequestsError, ServerError) + +# We rely on pytest-retry for exceptions *during* tests. +# We use tenacity for exceptions *before* tests. +# See https://github.com/str0zzapreti/pytest-retry/issues/33. +RETRY_ON_TOO_MANY_REQUESTS = retry( + retry=retry_if_exception_type(exception_types=RETRY_EXCEPTIONS), + wait=wait_fixed(wait=10), + reraise=True, +) diff --git a/tests/mock_vws/utils/too_many_requests.py b/tests/mock_vws/utils/too_many_requests.py new file mode 100644 index 000000000..c35cb54d1 --- /dev/null +++ b/tests/mock_vws/utils/too_many_requests.py @@ -0,0 +1,35 @@ +"""Helpers for handling too many requests errors.""" + +from http import HTTPStatus + +from beartype import beartype +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.response import Response + + +@beartype +def handle_server_errors(*, response: Response) -> None: + """Raise errors if the response is a 429 or 5xx. This is useful for + retrying tests based on the exceptions they raise. + + Raises: + vws.exceptions.vws_exceptions.TooManyRequestsError: The response is a + 429. + vws.exceptions.custom_exceptions.ServerError: The response is a 5xx. + """ + # We do not cover this because in some test runs we will not hit the + # error. + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + # We raise this here to prompt a retry at a higher level. + raise TooManyRequestsError(response=response) + + # We do not cover this because in some test runs we will not hit the + # error. + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) diff --git a/tests/mock_vws/utils/usage_test_helpers.py b/tests/mock_vws/utils/usage_test_helpers.py index fc21eb15d..708b1b168 100644 --- a/tests/mock_vws/utils/usage_test_helpers.py +++ b/tests/mock_vws/utils/usage_test_helpers.py @@ -1,60 +1,34 @@ -""" -Helpers for testing the usage of the mocks. -""" +"""Helpers for testing the usage of the mocks.""" + +import datetime import io -from datetime import datetime -from vws import VWS, CloudRecoService -from vws.exceptions.custom_exceptions import ( - ActiveMatchingTargetsDeleteProcessing, -) +from beartype import beartype +from vws import VWS from vws.reports import TargetStatuses -from mock_vws.database import VuforiaDatabase - - -def _add_and_delete_target( - image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> None: - """ - Add and delete a target with the given image. - """ - vws_client = VWS( - server_access_key=vuforia_database.server_access_key, - server_secret_key=vuforia_database.server_secret_key, - ) - - target_id = vws_client.add_target( - name='example_name', - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) +from mock_vws.database import CloudDatabase +@beartype def processing_time_seconds( - vuforia_database: VuforiaDatabase, + *, + vuforia_database: CloudDatabase, image: io.BytesIO, ) -> float: - """ - Return the time taken to process a target in the database. - """ + """Return the time taken to process a target in the database.""" vws_client = VWS( server_access_key=vuforia_database.server_access_key, server_secret_key=vuforia_database.server_secret_key, ) target_id = vws_client.add_target( - name='example', + name="example", width=1, image=image, active_flag=True, application_metadata=None, ) - start_time = datetime.now() + start_time = datetime.datetime.now(tz=datetime.UTC) while ( vws_client.get_target_record(target_id=target_id).status @@ -62,112 +36,5 @@ def processing_time_seconds( ): pass - return (datetime.now() - start_time).total_seconds() - - -def _wait_for_deletion_recognized( - image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> None: - """ - Wait until the query endpoint "recognizes" the deletion of all targets with - an image matching the given image. - - That is, wait until querying the given image does not return a result with - targets. - """ - cloud_reco_client = CloudRecoService( - client_access_key=vuforia_database.client_access_key, - client_secret_key=vuforia_database.client_secret_key, - ) - - while True: - try: - results = cloud_reco_client.query(image=image) - except ActiveMatchingTargetsDeleteProcessing: - return - - if not results: - return - - -def _wait_for_deletion_processed( - image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> None: - """ - Wait until the query endpoint "recognizes" the deletion of all targets with - an image matching the given image. - - That is, wait until querying the given image returns a result with no - targets. - """ - _wait_for_deletion_recognized( - image=image, - vuforia_database=vuforia_database, - ) - - cloud_reco_client = CloudRecoService( - client_access_key=vuforia_database.client_access_key, - client_secret_key=vuforia_database.client_secret_key, - ) - - while True: - try: - cloud_reco_client.query(image=image) - except ActiveMatchingTargetsDeleteProcessing: - continue - return - - -def recognize_deletion_seconds( - high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> float: - """ - The number of seconds it takes for the query endpoint to recognize a - deletion. - """ - _add_and_delete_target( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_after_deletion = datetime.now() - - _wait_for_deletion_recognized( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_difference = datetime.now() - time_after_deletion - return time_difference.total_seconds() - - -def process_deletion_seconds( - high_quality_image: io.BytesIO, - vuforia_database: VuforiaDatabase, -) -> float: - """ - The number of seconds it takes for the query endpoint to process a - deletion. - """ - _add_and_delete_target( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - _wait_for_deletion_recognized( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_after_deletion_recognized = datetime.now() - - _wait_for_deletion_processed( - image=high_quality_image, - vuforia_database=vuforia_database, - ) - - time_difference = datetime.now() - time_after_deletion_recognized - return time_difference.total_seconds() + processing_time = datetime.datetime.now(tz=datetime.UTC) - start_time + return processing_time.total_seconds() diff --git a/vuforia_secrets.env.example b/vuforia_secrets.env.example index 7133b01f1..760e0407e 100644 --- a/vuforia_secrets.env.example +++ b/vuforia_secrets.env.example @@ -1,15 +1,26 @@ -VUFORIA_TARGET_MANAGER_DATABASE_NAME= +VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_database_name -VUFORIA_SERVER_ACCESS_KEY= -VUFORIA_SERVER_SECRET_KEY= +VUFORIA_SERVER_ACCESS_KEY=example_server_access_key +VUFORIA_SERVER_SECRET_KEY=example_server_secret_key -VUFORIA_CLIENT_ACCESS_KEY= -VUFORIA_CLIENT_SECRET_KEY= +VUFORIA_CLIENT_ACCESS_KEY=example_client_access_key +VUFORIA_CLIENT_SECRET_KEY=example_client_secret_key -INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME= +INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_inactive_database_name -INACTIVE_VUFORIA_SERVER_ACCESS_KEY= -INACTIVE_VUFORIA_SERVER_SECRET_KEY= +INACTIVE_VUFORIA_SERVER_ACCESS_KEY=example_inactive_server_access_key +INACTIVE_VUFORIA_SERVER_SECRET_KEY=example_inactive_server_secret_key -INACTIVE_VUFORIA_CLIENT_ACCESS_KEY= -INACTIVE_VUFORIA_CLIENT_SECRET_KEY= +INACTIVE_VUFORIA_CLIENT_ACCESS_KEY=example_inactive_client_access_key +INACTIVE_VUFORIA_CLIENT_SECRET_KEY=example_inactive_client_secret_key + +VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_vumark_database_name +VUMARK_VUFORIA_TARGET_ID=examplevumarktargetid + +VUMARK_VUFORIA_SERVER_ACCESS_KEY=example_vumark_server_access_key +VUMARK_VUFORIA_SERVER_SECRET_KEY=example_vumark_server_secret_key + +INACTIVE_VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_inactive_vumark_database_name + +INACTIVE_VUMARK_VUFORIA_SERVER_ACCESS_KEY=example_inactive_vumark_server_access_key +INACTIVE_VUMARK_VUFORIA_SERVER_SECRET_KEY=example_inactive_vumark_server_secret_key diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 000000000..ce05fe5e8 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,10 @@ +--- +rules: + unpinned-uses: + disable: true + cache-poisoning: + disable: true + dependabot-cooldown: + disable: true + superfluous-actions: + disable: true