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 79d0e38c9..a2e641793 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,13 @@ +--- version: 2 + updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 -- package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: daily + - 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 index e01d924ed..47eb2d547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: 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: {} # We share Vuforia credentials and therefore Vuforia databases across @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12"] + python-version: ['3.13'] ci_pattern: - tests/mock_vws/test_query.py::TestContentType - tests/mock_vws/test_query.py::TestSuccess @@ -124,12 +124,10 @@ jobs: # See https://github.com/codecov/codecov-action/issues/190. fetch-depth: 2 - - name: "Set up Python" - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v5 - - name: "Set secrets file" + - name: Set secrets file run: | # See the "CI Setup" document for details of how this was set up. ci/decrypt_secret.sh @@ -140,12 +138,6 @@ jobs: ENCRYPTED_FILE: secrets.tar.gpg LARGE_SECRET_PASSPHRASE: ${{ secrets.PASSPHRASE_FOR_VUFORIA_SECRETS }} - # We do not use the cache action as uv is faster than the cache action. - - name: "Install dependencies" - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - uv pip install --system --upgrade --editable .[dev] - # We have seen issues with running out of disk space on test_docker - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@main @@ -161,9 +153,9 @@ jobs: dotnet: true haskell: true - - name: "Run tests" + - name: Run tests run: | - pytest \ + uv run --extra=dev pytest \ -s \ -vvv \ --showlocals \ @@ -172,8 +164,10 @@ jobs: --cov=tests/ \ --cov-report=xml \ ${{ matrix.ci_pattern }} + env: + UV_PYTHON: ${{ matrix.python-version }} - - name: "Show coverage file" + - name: Show coverage file run: | # Sometimes we have been sure that we have 100% coverage, but codecov # says otherwise. @@ -190,12 +184,12 @@ jobs: # # 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" + - name: Show event name run: | echo ${{ github.event_name }} - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v4" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 8867074f0..9a8343f30 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -14,7 +14,7 @@ on: 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: {} jobs: @@ -39,11 +39,11 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build Docker image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.13.0 with: platforms: linux/amd64,linux/arm64 file: src/mock_vws/_flask_server/Dockerfile push: false target: ${{ matrix.image.name }} - tags: | + tags: |- adamtheturtle/vuforia-${{ matrix.image.name }}-mock:latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f09270f0d..b73a22dbd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,35 +10,32 @@ on: 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: {} jobs: build: - runs-on: ubuntu-latest - strategy: matrix: - python-version: ["3.12"] + python-version: ['3.13'] + platform: [ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.platform }} + steps: - uses: actions/checkout@v4 - - name: "Set up Python" - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - # We do not use the cache action as uv is faster than the cache action. - - name: "Install dependencies" - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - uv pip install --system --upgrade --editable .[dev] + - name: Install uv + uses: astral-sh/setup-uv@v5 - - name: "Lint" + - name: Lint run: | - pre-commit run --all-files --hook-stage commit --verbose - pre-commit run --all-files --hook-stage push --verbose - pre-commit run --all-files --hook-stage manual --verbose + uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose + uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose + uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose + env: + UV_PYTHON: ${{ matrix.python-version }} - - uses: pre-commit-ci/lite-action@v1.0.2 + - uses: pre-commit-ci/lite-action@v1.1.0 if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c4d5f2f..ebd5fa885 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,39 +22,50 @@ jobs: strategy: matrix: - python-version: ["3.12"] + python-version: ['3.13'] steps: - uses: actions/checkout@v4 - - - name: "Set up Python" - uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-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 + + - name: Install uv + uses: astral-sh/setup-uv@v5 - - name: "Calver calculate version" + - 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" + - name: Get the changelog underline + id: changelog_underline + run: | + underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')" + echo "underline=${underline}" >> "$GITHUB_OUTPUT" + + - name: Update changelog uses: jacobtomlinson/gha-find-replace@v3 - env: - NEXT_VERSION: ${{ steps.calver.outputs.release }} with: find: "Next\n----" - replace: "Next\n----\n\n${{ env.NEXT_VERSION }}\n------------" - include: "CHANGELOG.rst" + replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ + \ }}" + include: CHANGELOG.rst regex: false - uses: stefanzweifel/git-auto-commit-action@v5 id: commit with: commit_message: Bump CHANGELOG + file_pattern: CHANGELOG.rst - name: Bump version and push tag id: tag_version @@ -62,7 +73,7 @@ jobs: 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 @@ -74,12 +85,10 @@ jobs: - 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 check-wheel-contents - python -m build --sdist --wheel --outdir dist/ . - check-wheel-contents dist/*.whl + git checkout ${{ steps.tag_version.outputs.new_tag }} + 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. @@ -101,7 +110,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Build and push target manager Docker image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.13.0 with: file: src/mock_vws/_flask_server/Dockerfile platforms: linux/amd64,linux/arm64 @@ -112,7 +121,7 @@ jobs: adamtheturtle/vuforia-target-manager-mock:${{ steps.calver.outputs.release }} - name: Build and push VWS Docker image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.13.0 with: file: src/mock_vws/_flask_server/Dockerfile platforms: linux/amd64,linux/arm64 @@ -123,12 +132,12 @@ jobs: adamtheturtle/vuforia-vws-mock:${{ steps.calver.outputs.release }} - name: Build and push VWQ Docker image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.13.0 with: file: src/mock_vws/_flask_server/Dockerfile platforms: linux/amd64,linux/arm64 push: true target: vwq - tags: | + tags: |- adamtheturtle/vuforia-vwq-mock:latest adamtheturtle/vuforia-vwq-mock:${{ steps.calver.outputs.release }} diff --git a/.github/workflows/skip-tests.yml b/.github/workflows/skip-tests.yml index 85d616821..db42e823b 100644 --- a/.github/workflows/skip-tests.yml +++ b/.github/workflows/skip-tests.yml @@ -12,7 +12,7 @@ on: 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: {} jobs: @@ -20,7 +20,7 @@ jobs: strategy: matrix: - python-version: ["3.12"] + python-version: ['3.13'] platform: [ubuntu-latest] runs-on: ${{ matrix.platform }} @@ -31,24 +31,16 @@ jobs: # See https://github.com/codecov/codecov-action/issues/190. fetch-depth: 2 - - name: "Set up Python" - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - # We do not use the cache action as uv is faster than the cache action. - - name: "Install dependencies" - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - uv pip install --system --upgrade --editable .[dev] + - name: Install uv + uses: astral-sh/setup-uv@v5 - - name: "Set secrets file" + - name: Set secrets file run: | cp ./vuforia_secrets.env.example ./vuforia_secrets.env - - name: "Run tests" + - name: Run tests run: | - pytest \ + uv run --extra=dev pytest \ --skip-docker_build_tests \ --skip-docker_in_memory \ --skip-mock \ @@ -59,9 +51,11 @@ jobs: --cov=src/ \ --cov=tests/ \ --cov-report=xml \ - tests/mock_vws/ + . + env: + UV_PYTHON: ${{ matrix.python-version }} - - name: "Show coverage file" + - name: Show coverage file run: | # Sometimes we have been sure that we have 100% coverage, but codecov # says otherwise. @@ -78,12 +72,12 @@ jobs: # # 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" + - name: Show event name run: | echo ${{ github.event_name }} - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v4" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: fail_ci_if_error: true # See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954 diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index b73e0905f..57d86c41a 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -10,7 +10,7 @@ on: 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: {} jobs: @@ -18,7 +18,7 @@ jobs: strategy: matrix: - python-version: ["3.12"] + python-version: ['3.13'] platform: [windows-latest] runs-on: ${{ matrix.platform }} @@ -29,28 +29,22 @@ jobs: # See https://github.com/codecov/codecov-action/issues/190. fetch-depth: 2 - - name: "Set up Python" - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - # We do not use the cache action as uv is faster than the cache action. - - name: "Install dependencies" - run: | - irm https://astral.sh/uv/install.ps1 | iex - uv pip install --system --upgrade --editable .[dev] + - name: Install uv + uses: astral-sh/setup-uv@v5 - - name: "Set secrets file" + - name: Set secrets file run: | cp ./vuforia_secrets.env.example ./vuforia_secrets.env - - name: "Run tests" + - name: Run tests run: | # We use pytest-xdist to make this run much faster. # The downside is that we cannot use -s / --capture=no. - pytest --skip-real -vvv --exitfirst -n auto --cov=src/ --cov=tests/ --cov-report=xml tests/mock_vws/ + uv run --extra=dev pytest --skip-real -vvv --exitfirst -n auto --cov=src/ --cov=tests/ --cov-report=xml . + env: + UV_PYTHON: ${{ matrix.python-version }} - - name: "Show coverage file" + - name: Show coverage file run: | # Sometimes we have been sure that we have 100% coverage, but codecov # says otherwise. @@ -67,12 +61,12 @@ jobs: # # 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" + - name: Show event name run: | echo ${{ github.event_name }} - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v4" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: fail_ci_if_error: true # See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954 diff --git a/.gitignore b/.gitignore index ec1645423..556e31308 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,5 @@ secrets.tar # setuptools_scm src/*/_setuptools_scm_version.txt + +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bd006349a..342a9d887 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,181 +1,326 @@ -# 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, commit-msg] -repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: check-added-large-files - - id: check-case-conflict - - id: check-executables-have-shebangs - - id: check-merge-conflict - - id: check-shebang-scripts-are-executable - - id: check-symlinks - - id: check-toml - - id: check-vcs-permalinks - - id: check-yaml - - id: end-of-file-fixer - - id: file-contents-sorter - files: spelling_private_dict\.txt$ - - id: trailing-whitespace - exclude: ^src/mock_vws/resources/ -- repo: local - hooks: - - id: custom-linters - name: custom-linters - entry: pytest ci/custom_linters.py - stages: [push] - language: system - types_or: [yaml, python] - pass_filenames: false - - - id: actionlint - name: actionlint - entry: actionlint - language: system - pass_filenames: false - types_or: [yaml] - - - id: mypy - name: mypy - stages: [push] - entry: mypy . - language: system - types_or: [python, toml] - pass_filenames: false - - - id: check-manifest - name: check-manifest - stages: [push] - entry: check-manifest . - language: system - pass_filenames: false - - - id: pyright - name: pyright - stages: [push] - entry: pyright . - language: system - types_or: [python, toml] - pass_filenames: false - - - id: pyright-verifytypes - name: pyright-verifytypes - stages: [push] - entry: pyright --verifytypes mock_vws - language: system - pass_filenames: false - types_or: [python] - - - id: vulture - name: vulture - entry: vulture --min-confidence 100 --exclude .eggs - language: system - types_or: [python] - - - id: pyroma - name: pyroma - entry: pyroma --min 10 . - language: system - pass_filenames: false - types_or: [toml] - - - id: deptry - name: deptry - entry: deptry src/ - language: system - pass_filenames: false - - - id: pylint - name: pylint - entry: pylint *.py src/ tests/ docs/ ci/ - language: system - stages: [manual] - pass_filenames: false - - - id: hadolint-docker - name: Lint Dockerfiles - description: Runs hadolint Docker image to lint Dockerfiles - language: docker_image - types_or: [dockerfile] - stages: [manual] # Requires Docker to be running - # We choose not to use a Python wrapper or alternative to hadolint as none - # appear to be well maintained, and they require more setup than we would - # want. - entry: ghcr.io/hadolint/hadolint hadolint - - - id: ruff-check-fix - name: Ruff check fix - entry: ruff check --fix - language: system - types_or: [python] - - - id: ruff-format-fix - name: Ruff format - entry: ruff format - language: system - types_or: [python] - - - id: doc8 - name: doc8 - entry: doc8 - language: system - types_or: [rst] - - - id: interrogate - name: interrogate - entry: interrogate src/ tests/ ci/ - language: system - types_or: [python] - - - id: pyproject-fmt-fix - name: pyproject-fmt - entry: pyproject-fmt - language: system - types_or: [toml] - files: pyproject.toml - - - id: linkcheck - name: linkcheck - entry: make -C docs/ linkcheck SPHINXOPTS=-W - language: system - types_or: [rst] - stages: [manual] - pass_filenames: false - - - id: spelling - name: spelling - entry: make -C docs/ spelling SPHINXOPTS=-W - language: system - types_or: [rst] - stages: [manual] - pass_filenames: false - - - id: docs - name: Build Documentation - entry: make docs - language: system - stages: [manual] - pass_filenames: false +--- +fail_fast: true + # We use system Python, with required dependencies specified in pyproject.toml. # We therefore cannot use those dependencies in pre-commit CI. ci: skip: - - custom-linters - - actionlint - - mypy - - check-manifest - - pyright - - pyright-verifytypes - - vulture - - pyroma - - deptry - - pylint - - ruff-check-fix - - ruff-format-fix - - doc8 - - interrogate - - pyproject-fmt-fix - - linkcheck - - spelling - - docs + - 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 + - pyroma + - ruff-check-fix + - ruff-check-fix-docs + - ruff-format-fix + - ruff-format-fix-docs + - docformatter + - shellcheck + - shellcheck-docs + - shfmt + - shfmt-docs + - spelling + - vulture + - vulture-docs + - yamlfix + +# 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, commit-msg] + +repos: + - repo: meta + hooks: + - id: check-useless-excludes + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: check-json + - id: check-toml + - id: check-vcs-permalinks + - id: check-yaml + - id: end-of-file-fixer + - id: file-contents-sorter + files: spelling_private_dict\.txt$ + - id: trailing-whitespace + exclude: ^src/mock_vws/resources/ + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: rst-directive-colons + - id: rst-inline-touching-normal + - id: text-unicode-replacement-char + - id: rst-backticks + + - repo: https://github.com/AleksaC/hadolint-py + rev: v2.12.1b3 + hooks: + - id: hadolint + + - 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==0.5.20] + + - id: actionlint + name: actionlint + entry: uv run --extra=dev actionlint + language: python + pass_filenames: false + types_or: [yaml] + additional_dependencies: [uv==0.5.20] + + - id: docformatter + name: docformatter + entry: uv run --extra=dev -m docformatter --in-place + language: python + types_or: [python] + additional_dependencies: [uv==0.5.20] + + - id: shellcheck + name: shellcheck + entry: uv run --extra=dev shellcheck --shell=bash + language: python + types_or: [shell] + additional_dependencies: [uv==0.5.20] + + - 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 --language=shell --language=console --command="shellcheck + --shell=bash --exclude=SC2215" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.5.20] + + - id: shfmt + name: shfmt + entry: shfmt --write --space-redirects --indent=4 + language: python + types_or: [shell] + additional_dependencies: [uv==0.5.20] + + - 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==0.5.20] + + - 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==0.5.20] + + - id: mypy-docs + name: mypy-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --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==0.5.20] + + - 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==0.5.20] + + - id: pyright-docs + name: pyright-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --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==0.5.20] + + - id: vulture + name: vulture + entry: uv run --extra=dev -m vulture . + language: python + types_or: [python] + pass_filenames: false + additional_dependencies: [uv==0.5.20] + + - id: vulture-docs + name: vulture docs + entry: uv run --extra=dev doccmd --language=python --command="vulture" + language: python + types_or: [python] + pass_filenames: false + additional_dependencies: [uv==0.5.20] + + - id: pyroma + name: pyroma + entry: uv run --extra=dev -m pyroma --min 10 . + language: python + pass_filenames: false + types_or: [toml] + additional_dependencies: [uv==0.5.20] + + - id: deptry + name: deptry + entry: uv run --extra=dev -m deptry src/ + language: python + pass_filenames: false + additional_dependencies: [uv==0.5.20] + + - 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==0.5.20] + + - id: pylint-docs + name: pylint-docs + entry: uv run --extra=dev doccmd --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==0.5.20] + + - 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==0.5.20] + + - id: ruff-format-fix + name: Ruff format + entry: uv run --extra=dev -m ruff format + language: python + types_or: [python] + additional_dependencies: [uv==0.5.20] + + - 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==0.5.20] + + - id: doc8 + name: doc8 + entry: uv run --extra=dev -m doc8 + language: python + types_or: [rst] + additional_dependencies: [uv==0.5.20] + + - id: interrogate + name: interrogate + entry: uv run --extra=dev -m interrogate + language: python + types_or: [python] + exclude_types: [executable] + + - id: interrogate-docs + name: interrogate docs + entry: uv run --extra=dev doccmd --language=python --command="interrogate" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.5.20] + + - id: pyproject-fmt-fix + name: pyproject-fmt + entry: uv run --extra=dev pyproject-fmt + language: python + types_or: [toml] + files: pyproject.toml + + - id: linkcheck + name: linkcheck + entry: make -C docs/ linkcheck SPHINXOPTS=-W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: [uv==0.5.20] + + - id: spelling + name: spelling + entry: make -C docs/ spelling SPHINXOPTS=-W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: [uv==0.5.20] + + - id: docs + name: Build Documentation + entry: make docs + language: python + stages: [manual] + pass_filenames: false + additional_dependencies: [uv==0.5.20] + + - id: yamlfix + name: pyproject-fmt + entry: uv run --extra=dev yamlfix + language: python + types_or: [yaml] + additional_dependencies: [uv==0.5.20] + + - 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==0.5.20] diff --git a/.vscode/settings.json b/.vscode/settings.json index 98f94ef23..57ee5a50a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,11 @@ "editor.formatOnSave": true }, "esbonio.sphinx.confDir": "", - "rewrap.wrappingColumn": 79 + "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 6ce822fb5..10b99fe02 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,9 @@ Changelog Next ---- +2025.02.18 +---------- + 2024.08.30 ------------ diff --git a/README.rst b/README.rst index 0cc335f84..d7be18605 100644 --- a/README.rst +++ b/README.rst @@ -13,15 +13,18 @@ Mocking calls made to Vuforia with Python ``requests`` Using the mock redirects requests to Vuforia made with `requests`_ to an in-memory implementation. -This requires Python 3.12+. - -.. code:: sh +.. code-block:: shell pip install vws-python-mock +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 from mock_vws.database import VuforiaDatabase @@ -29,7 +32,7 @@ This requires Python 3.12+. database = VuforiaDatabase() mock.add_database(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. @@ -51,7 +54,7 @@ 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/ci.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/main/graph/badge.svg :target: https://codecov.io/gh/VWS-Python/vws-python-mock @@ -60,3 +63,4 @@ This includes details on how to use the mock, options, and details of the differ .. |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.13 diff --git a/admin/__init__.py b/admin/__init__.py index 1a76e35be..6a8f8f73b 100644 --- a/admin/__init__.py +++ b/admin/__init__.py @@ -1 +1,3 @@ -"""Admin tools.""" +""" +Admin tools. +""" diff --git a/admin/create_secrets_files.py b/admin/create_secrets_files.py index 51930f58b..702091c87 100644 --- a/admin/create_secrets_files.py +++ b/admin/create_secrets_files.py @@ -1,5 +1,4 @@ -""" -Create licenses and target databases for the tests to run against. +"""Create licenses and target databases for the tests to run against. Usage: @@ -11,7 +10,6 @@ $ export EXISTING_SECRETS_FILE=/existing/file/with/inactive/db/creds # You may have to run this a few times, but it is idempotent. $ python admin/create_secrets_files.py - """ import datetime @@ -25,81 +23,97 @@ from selenium import webdriver from selenium.common.exceptions import TimeoutException -email_address = os.environ["VWS_EMAIL_ADDRESS"] -password = os.environ["VWS_PASSWORD"] -new_secrets_dir = Path(os.environ["NEW_SECRETS_DIR"]).expanduser() -existing_secrets_file = Path(os.environ["EXISTING_SECRETS_FILE"]).expanduser() -assert existing_secrets_file.exists(), existing_secrets_file -load_dotenv(dotenv_path=existing_secrets_file) -new_secrets_dir.mkdir(exist_ok=True) - -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()] -start_number = len(list(new_secrets_dir.glob("*"))) -driver = None - -while files_to_create: - if driver is None: - # With Safari we get a bunch of errors / timeouts. - driver = webdriver.Chrome() - file = files_to_create[-1] - sys.stdout.write(f"Creating database {file.name}\n") - time = datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d-%H-%M-%S") - license_name = f"my-license-{time}" - database_name = f"my-database-{time}" - - vws_web_tools.log_in( - driver=driver, - email_address=email_address, - password=password, - ) - vws_web_tools.wait_for_logged_in(driver=driver) - try: - vws_web_tools.create_license(driver=driver, license_name=license_name) - except TimeoutException: - sys.stderr.write("Timed out waiting for license creation\n") - driver.quit() - driver = None - continue - - vws_web_tools.create_database( - driver=driver, - database_name=database_name, - license_name=license_name, - ) - - try: - database_details = vws_web_tools.get_database_details( + +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() + existing_secrets_file = Path( + os.environ["EXISTING_SECRETS_FILE"] + ).expanduser() + if not existing_secrets_file.exists(): + msg = f"Existing secrets file does not exist: {existing_secrets_file}" + raise FileNotFoundError(msg) + load_dotenv(dotenv_path=existing_secrets_file) + new_secrets_dir.mkdir(exist_ok=True) + + 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 = None + + while files_to_create: + if driver is None: + # With Safari we get a bunch of errors / timeouts. + driver = webdriver.Chrome() + file = files_to_create[-1] + sys.stdout.write(f"Creating database {file.name}\n") + time = datetime.datetime.now(tz=datetime.UTC).strftime( + format="%Y-%m-%d-%H-%M-%S", + ) + license_name = f"my-license-{time}" + database_name = f"my-database-{time}" + + vws_web_tools.log_in( + driver=driver, + email_address=email_address, + password=password, + ) + vws_web_tools.wait_for_logged_in(driver=driver) + try: + vws_web_tools.create_license( + driver=driver, license_name=license_name + ) + except TimeoutException: + sys.stderr.write("Timed out waiting for license creation\n") + driver.quit() + driver = None + continue + + vws_web_tools.create_database( driver=driver, database_name=database_name, + license_name=license_name, + ) + + try: + database_details = vws_web_tools.get_database_details( + driver=driver, + database_name=database_name, + ) + except TimeoutException: + sys.stderr.write("Timed out waiting for database to be created\n") + continue + finally: + driver.quit() + driver = None + + file_contents = textwrap.dedent( + text=f"""\ + VUFORIA_TARGET_MANAGER_DATABASE_NAME={database_details["database_name"]} + VUFORIA_SERVER_ACCESS_KEY={database_details["server_access_key"]} + VUFORIA_SERVER_SECRET_KEY={database_details["server_secret_key"]} + VUFORIA_CLIENT_ACCESS_KEY={database_details["client_access_key"]} + VUFORIA_CLIENT_SECRET_KEY={database_details["client_secret_key"]} + + INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME={os.environ["INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME"]} + INACTIVE_VUFORIA_SERVER_ACCESS_KEY={os.environ["INACTIVE_VUFORIA_SERVER_ACCESS_KEY"]} + INACTIVE_VUFORIA_SERVER_SECRET_KEY={os.environ["INACTIVE_VUFORIA_SERVER_SECRET_KEY"]} + INACTIVE_VUFORIA_CLIENT_ACCESS_KEY={os.environ["INACTIVE_VUFORIA_CLIENT_ACCESS_KEY"]} + INACTIVE_VUFORIA_CLIENT_SECRET_KEY={os.environ["INACTIVE_VUFORIA_CLIENT_SECRET_KEY"]} + """, ) - except TimeoutException: - sys.stderr.write("Timed out waiting for database to be created\n") - continue - finally: - driver.quit() - driver = None - - file_contents = textwrap.dedent( - f"""\ - VUFORIA_TARGET_MANAGER_DATABASE_NAME={database_details["database_name"]} - VUFORIA_SERVER_ACCESS_KEY={database_details["server_access_key"]} - VUFORIA_SERVER_SECRET_KEY={database_details["server_secret_key"]} - VUFORIA_CLIENT_ACCESS_KEY={database_details["client_access_key"]} - VUFORIA_CLIENT_SECRET_KEY={database_details["client_secret_key"]} - - INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME={os.environ["INACTIVE_VUFORIA_TARGET_MANAGER_DATABASE_NAME"]} - INACTIVE_VUFORIA_SERVER_ACCESS_KEY={os.environ["INACTIVE_VUFORIA_SERVER_ACCESS_KEY"]} - INACTIVE_VUFORIA_SERVER_SECRET_KEY={os.environ["INACTIVE_VUFORIA_SERVER_SECRET_KEY"]} - INACTIVE_VUFORIA_CLIENT_ACCESS_KEY={os.environ["INACTIVE_VUFORIA_CLIENT_ACCESS_KEY"]} - INACTIVE_VUFORIA_CLIENT_SECRET_KEY={os.environ["INACTIVE_VUFORIA_CLIENT_SECRET_KEY"]} - """, - ) - - file.write_text(file_contents) - sys.stdout.write(f"Created database {file.name}\n") - files_to_create.pop() + + 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/ci/__init__.py b/ci/__init__.py index 4b867b2bd..fdd0b5af8 100644 --- a/ci/__init__.py +++ b/ci/__init__.py @@ -1 +1,3 @@ -"""CI helpers.""" +""" +CI helpers. +""" diff --git a/ci/custom_linters.py b/ci/test_custom_linters.py similarity index 91% rename from ci/custom_linters.py rename to ci/test_custom_linters.py index 784c8cac5..8de79cbb8 100644 --- a/ci/custom_linters.py +++ b/ci/test_custom_linters.py @@ -3,11 +3,15 @@ """ from pathlib import Path +from typing import TYPE_CHECKING import pytest import yaml from beartype import beartype +if TYPE_CHECKING: + from collections.abc import Iterable + @beartype def _ci_patterns(*, repository_root: Path) -> set[str]: @@ -15,7 +19,7 @@ def _ci_patterns(*, repository_root: Path) -> set[str]: Return the CI patterns given in the CI configuration file. """ ci_file = repository_root / ".github" / "workflows" / "ci.yml" - github_workflow_config = yaml.safe_load(ci_file.read_text()) + github_workflow_config = yaml.safe_load(stream=ci_file.read_text()) matrix = github_workflow_config["jobs"]["build"]["strategy"]["matrix"] ci_pattern_list = matrix["ci_pattern"] ci_patterns = set(ci_pattern_list) @@ -34,7 +38,7 @@ def _tests_from_pattern( """ # Clear the captured output. capsys.readouterr() - tests: set[str] = set() + tests: Iterable[str] = set() pytest.main( args=[ "-q", @@ -49,8 +53,8 @@ def _tests_from_pattern( # We filter empty lines and lines which look like # "9 tests collected in 0.01s". if line and "collected in" not in line: - tests.add(line) - return tests + tests = {*tests, line} + return set(tests) def test_ci_patterns_valid(request: pytest.FixtureRequest) -> None: @@ -87,8 +91,7 @@ def test_tests_collected_once( capsys: pytest.CaptureFixture[str], request: pytest.FixtureRequest, ) -> None: - """ - Each test in the test suite is collected exactly once. + """Each test in the test suite is collected exactly once. This does not necessarily mean that they are run - they may be skipped. """ diff --git a/codecov.yaml b/codecov.yaml index e49034f39..5c35baac9 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -1,3 +1,4 @@ +--- coverage: status: patch: diff --git a/conftest.py b/conftest.py index f7b14d1d7..ed36d6500 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,6 @@ -"""Setup for Sybil.""" +""" +Setup for Sybil. +""" from doctest import ELLIPSIS @@ -35,8 +37,7 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: @beartype @pytest.hookimpl(optionalhook=True) def pytest_set_filtered_exceptions() -> tuple[type[Exception], ...]: - """ - Return exceptions to retry on. + """Return exceptions to retry on. This is for ``pytest-retry``. The configuration for retries is in ``pyproject.toml``. diff --git a/docs/Makefile b/docs/Makefile index aae2ad2a1..7aba47eda 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -4,17 +4,16 @@ # 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) + @uv run --extra=dev $(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) + @uv run --extra=dev $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/__init__.py b/docs/source/__init__.py index 535ceb2ec..b63eed5fb 100644 --- a/docs/source/__init__.py +++ b/docs/source/__init__.py @@ -1 +1,3 @@ -"""Documentation.""" +""" +Documentation. +""" diff --git a/docs/source/basic-example.rst b/docs/source/basic-example.rst index 2c385ac0e..35c12e200 100644 --- a/docs/source/basic-example.rst +++ b/docs/source/basic-example.rst @@ -2,7 +2,10 @@ Using the mock redirects requests to Vuforia made with `requests`_ to an in-memo .. code-block:: python + """Make a request to the Vuforia Web Services API mock.""" + import requests + from mock_vws import MockVWS from mock_vws.database import VuforiaDatabase @@ -10,8 +13,7 @@ Using the mock redirects requests to Vuforia made with `requests`_ to an in-memo database = VuforiaDatabase() mock.add_database(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. diff --git a/docs/source/ci-setup.rst b/docs/source/ci-setup.rst index b118d20bb..0c46b4d0b 100644 --- a/docs/source/ci-setup.rst +++ b/docs/source/ci-setup.rst @@ -22,7 +22,7 @@ Create environment variable files for secrets: $ 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. @@ -35,7 +35,7 @@ Add the encrypted secrets files to the repository: .. code-block:: console - $ PASSPHRASE_FOR_VUFORIA_SECRETS= make update-secrets + $ PASSPHRASE_FOR_VUFORIA_SECRETS="" make update-secrets $ 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 337869ef0..510bf6860 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,11 +3,10 @@ Configuration for Sphinx. """ -# pylint: disable=invalid-name - -import datetime import importlib.metadata +from packaging.specifiers import SpecifierSet + project = "VWS-Python-Mock" author = "Adam Dangoor" @@ -28,8 +27,7 @@ source_suffix = ".rst" master_doc = "index" -year = datetime.datetime.now(tz=datetime.UTC).year -project_copyright = f"{year}, {author}" +project_copyright = f"%Y, {author}" # Exclude the prompt from copied code with sphinx_copybutton. # https://sphinx-copybutton.readthedocs.io/en/latest/use.html#automatic-exclusion-of-prompts-from-the-copies. @@ -42,31 +40,37 @@ # Use ``importlib.metadata.version`` as per # https://setuptools-scm.readthedocs.io/en/latest/usage/#usage-from-sphinx. version = importlib.metadata.version(distribution_name=project) -_month, _day, _year, *_ = version.split(sep=".") -release = f"{_month}.{_day}.{_year}" +# This method of getting the release from the version goes hand in hand with +# the ``post-release`` versioning scheme chosen in the ``setuptools-scm`` +# configuration. +release = version.split(sep=".post")[0] + + +project_metadata = importlib.metadata.metadata(distribution_name=project) +requires_python = project_metadata["Requires-Python"] +specifiers = SpecifierSet(specifiers=requires_python) +(specifier,) = specifiers +if specifier.operator != ">=": + msg = ( + f"We only support '>=' for Requires-Python, got {specifier.operator}." + ) + raise ValueError(msg) +minimum_python_version = specifier.version language = "en" # The name of the syntax highlighting style to use. pygments_style = "sphinx" -python_minimum_supported_version = "3.12" - # Output file base name for HTML help builder. htmlhelp_basename = "VWSPYTHONMOCKdoc" autoclass_content = "init" intersphinx_mapping = { - "python": ( - f"https://docs.python.org/{python_minimum_supported_version}", - 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"), -] html_theme = "furo" html_title = project @@ -85,9 +89,9 @@ autodoc_member_order = "bysource" rst_prolog = f""" -.. |python-minimum-version| replace:: {python_minimum_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 315071d7d..daa07ba76 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -40,8 +40,8 @@ Run lint tools either by committing, or with: .. code-block:: console - $ pre-commit run --all-files --hook-stage commit --verbose - $ pre-commit run --all-files --hook-stage push --verbose + $ pre-commit run --all-files --hook-stage pre-commit --verbose + $ pre-commit run --all-files --hook-stage pre-push --verbose $ pre-commit run --all-files --hook-stage manual --verbose .. _Homebrew: https://brew.sh @@ -102,14 +102,14 @@ Skipping Some Tests Use the following custom ``pytest`` options to skip some tests: -.. code-block:: console +.. 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 + --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 ------------- diff --git a/docs/source/docker.rst b/docs/source/docker.rst index bb63c6917..e46c5d146 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -61,9 +61,9 @@ For example, with the containers set up as in :ref:`creating-containers`, use `` .. 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/databases' { "client_access_key": "2d61c1d17bb94694bee77c1f1f41e5d9", "client_secret_key": "b73f8170cf7d42728fa8ce66221ad147", @@ -154,13 +154,13 @@ Building images from source .. code-block:: console - $ export REPOSITORY_ROOT=$PWD - $ export DOCKERFILE=$REPOSITORY_ROOT/src/mock_vws/_flask_server/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 - $ 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 + $ 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/index.rst b/docs/source/index.rst index 4baabda22..6f39583a2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,7 +8,7 @@ Mocking calls made to Vuforia with Python ``requests`` $ pip install vws-python-mock -This requires Python |python-minimum-version|\+. +This requires Python |minimum-python-version|\+. .. include:: basic-example.rst diff --git a/docs/source/installation.rst b/docs/source/installation.rst index ddcf7f2c3..ce56603b2 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -5,4 +5,4 @@ Installation $ pip install vws-python-mock -This requires Python |python-minimum-version|\+. +This requires Python |minimum-python-version|\+. diff --git a/docs/source/mock-api-reference.rst b/docs/source/mock-api-reference.rst index 0a2d4e180..ecb44f90b 100644 --- a/docs/source/mock-api-reference.rst +++ b/docs/source/mock-api-reference.rst @@ -7,6 +7,10 @@ API Reference :members: :undoc-members: +.. autoclass:: mock_vws.MissingSchemeError + :members: + :undoc-members: + .. Many parts of the VuforiaDatabase API are used for the Flask target .. database app, but Python users are not expected to use them. .. Therefore, they are not documented. diff --git a/docs/source/release-process.rst b/docs/source/release-process.rst index 0c662f553..db1744feb 100644 --- a/docs/source/release-process.rst +++ b/docs/source/release-process.rst @@ -17,6 +17,6 @@ Perform a Release .. 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/ diff --git a/pyproject.toml b/pyproject.toml index 6cd9ff3a0..0d50046ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,8 @@ [build-system] build-backend = "setuptools.build_meta" requires = [ - "pip", "setuptools", - "setuptools-scm[toml]==7.1", + "setuptools-scm>=8.1.0", "wheel", ] @@ -22,83 +21,93 @@ license = { file = "LICENSE" } authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, ] -requires-python = ">=3.12" +requires-python = ">=3.13" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Pytest", "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dynamic = [ "version", ] dependencies = [ - "beartype", - "flask", - # Pin numpy to avoid: - # https://github.com/pytorch/pytorch/issues/128860 - "numpy<2.0.0", - "pillow", - "piq", - "pydantic-settings", - "requests", - "responses", - "torch", - "torchmetrics", + "beartype>=0.19.0", + "flask>=3.0.3", + "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", + "torch>=2.5.1", + "torchmetrics>=1.5.1", "tzdata; sys_platform=='win32'", - "vws-auth-tools", - "werkzeug", + "vws-auth-tools>=2024.7.12", + "werkzeug>=3.1.2", ] optional-dependencies.dev = [ - "actionlint-py==1.7.1.15", - "check-manifest==0.49", - "check-wheel-contents==0.6.0", - "deptry==0.20.0", - "dirty-equals==0.8.0", + "actionlint-py==1.7.7.23", + "check-manifest==0.50", + "check-wheel-contents==0.6.1", + "deptry==0.23.0", + "dirty-equals==0.9.0", "doc8==1.1.1", + "doccmd==2025.1.11", + "docformatter==1.7.5", "docker==7.1.0", "enum-tools[sphinx]==0.12.0", "freezegun==1.5.1", "furo==2024.8.6", "interrogate==1.7.0", - "mypy==1.11.2", - "pre-commit==3.8.0", + "mypy[faster-cache]==1.15.0", + "mypy-strict-kwargs==2024.12.25", + "pre-commit==4.1.0", "pydocstyle==6.3", - "pyenchant==3.2.2", - "pylint==3.2.6", - "pyproject-fmt==2.2.1", - "pyright==1.1.378", + "pyenchant==3.3.0rc1", + "pylint==3.3.4", + "pylint-per-file-ignores==1.4.0", + "pyproject-fmt==2.5.0", + "pyright==1.1.393", "pyroma==4.2", - "pytest==8.3.2", - "pytest-cov==5.0.0", - "pytest-retry==1.6.3", + "pytest==8.3.4", + "pytest-cov==6.0.0", + "pytest-retry==1.7.0", "pytest-xdist==3.6.1", "python-dotenv==1.0.1", "pyyaml==6.0.2", - "requests-mock-flask==2024.8.30.1", - "ruff==0.6.3", - "sphinx==8.0.2", + "requests-mock-flask==2025.1.13", + "ruff==0.9.6", + # 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.10.0.1", + "shfmt-py==3.7.0.1", + "sphinx==8.1.3", "sphinx-copybutton==0.5.2", + "sphinx-lint==1.0.0", "sphinx-paramlinks==0.6", - "sphinx-substitution-extensions==2024.8.6", - "sphinx-toolbox==3.8.0", + "sphinx-substitution-extensions==2025.1.2", + "sphinx-toolbox==3.8.2", "sphinxcontrib-httpdomain==1.8.1", - "sphinxcontrib-spelling==8", - "sybil==6.1.1", + "sphinxcontrib-spelling==8.0.1", + "sybil==9.0.0", "tenacity==9.0.0", - "types-docker==7.1.0.20240827", - "types-pillow==10.2.0.20240822", - "types-pyyaml==6.0.12.20240808", - "types-requests==2.32.0.20240712", - "urllib3==2.2.2", - "vulture==2.11", - "vws-python==2024.2.19", + "types-docker==7.1.0.20241229", + "types-pyyaml==6.0.12.20241230", + "types-requests==2.32.0.20241016", + "urllib3==2.3.0", + "vulture==2.14", + "vws-python==2024.9.21", "vws-test-fixtures==2023.3.5", - "vws-web-tools==2023.12.26", + "vws-web-tools==2024.10.6.1", + "yamlfix==1.17.0", ] +optional-dependencies.release = [ "check-wheel-contents==0.6.1" ] urls.Documentation = "https://vws-python-mock.readthedocs.io" urls.Source = "https://github.com/VWS-Python/vws-python-mock" @@ -127,6 +136,13 @@ universal = true # 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" + [tool.ruff] target-version = "py311" @@ -135,9 +151,6 @@ lint.select = [ "ALL", ] lint.ignore = [ - # We do not annotate the type of 'self', or 'cls'. - "ANN101", - "ANN102", # Ruff warns that this conflicts with the formatter. "COM812", # Allow our chosen docstring line-style - no one-line summary. @@ -154,10 +167,17 @@ lint.ignore = [ # Also, allow 'assert' in other code as it is the standard for Python type hint # narrowing - see # https://mypy.readthedocs.io/en/stable/type_narrowing.html#type-narrowing-expressions. + # "S101", +] + +lint.per-file-ignores."ci/test_custom_linters.py" = [ + # Allow asserts in tests. "S101", ] lint.per-file-ignores."tests/**" = [ + # Allow asserts in tests. + "S101", # Allow possible hardcoded passwords in tests. "S105", "S106", @@ -190,6 +210,7 @@ jobs = 0 # - pylint.extensions.while_used # as they seemed to get in the way. load-plugins = [ + "pylint_per_file_ignores", 'pylint.extensions.bad_builtin', 'pylint.extensions.comparison_placement', 'pylint.extensions.consider_refactoring_into_while_condition', @@ -207,6 +228,14 @@ load-plugins = [ '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 +per-file-ignores = [ + "docs/:invalid-name", + "doccmd_README_rst.*.py:invalid-name", +] + [tool.pylint.'MESSAGES CONTROL'] # Enable the message, report, category or checker with the given id(s). You can @@ -235,26 +264,23 @@ enable = [ disable = [ # 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 + # Let ruff handle long lines 'line-too-long', - # Let flake8 handle unused imports + # Let ruff handle unused imports 'unused-import', - # Let isort deal with sorting + # 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 isort handle imports + # Let ruff handle imports 'wrong-import-order', ] @@ -277,9 +303,14 @@ spelling-private-dict-file = 'spelling_private_dict.txt' # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words = 'no' +[tool.docformatter] +make-summary-multi-line = true + [tool.check-manifest] ignore = [ + ".checkmake-config.ini", + ".yamlfmt", "*.enc", "admin/**", "readthedocs.yaml", @@ -306,6 +337,7 @@ ignore = [ [tool.deptry] pep621_dev_dependency_groups = [ "dev", + "release", ] [tool.deptry.per_rule_ignores] @@ -318,6 +350,7 @@ DEP002 = [ [tool.pyproject-fmt] indent = 4 keep_full_version = true +max_supported_python = "3.13" [tool.pytest.ini_options] @@ -352,11 +385,17 @@ exclude_also = [ [tool.mypy] strict = true +files = [ "." ] +exclude = [ "build" ] plugins = [ "pydantic.mypy", + "mypy_strict_kwargs", ] +follow_untyped_imports = true [tool.pyright] + +enableTypeIgnoreComments = false reportUnnecessaryTypeIgnoreComment = true typeCheckingMode = "strict" @@ -382,3 +421,62 @@ ignore_path = [ "./src/*.egg-info/", "./src/*/_setuptools_scm_version.txt", ] + +[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_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", + "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", +] + +# Duplicate some of .gitignore +exclude = [ ".venv" ] +ignore_decorators = [ + "@pytest.fixture", + # Flask + "@*APP.route", + "@*APP.before_request", + "@*APP.errorhandler", +] + +[tool.yamlfix] +section_whitelines = 1 +whitelines = 1 diff --git a/readthedocs.yaml b/readthedocs.yaml index 88e40e66a..6fecd53f0 100644 --- a/readthedocs.yaml +++ b/readthedocs.yaml @@ -1,17 +1,18 @@ +--- version: 2 build: - os: ubuntu-20.04 + os: ubuntu-24.04 tools: - python: "3.12" + python: '3.13' python: install: - - method: pip - path: . - extra_requirements: - - dev + - method: pip + path: . + extra_requirements: [dev] sphinx: builder: html + configuration: docs/source/conf.py fail_on_warning: true diff --git a/src/mock_vws/__init__.py b/src/mock_vws/__init__.py index adb792a16..357f764b7 100644 --- a/src/mock_vws/__init__.py +++ b/src/mock_vws/__init__.py @@ -2,8 +2,12 @@ Tools for using a fake implementation of Vuforia. """ -from mock_vws._requests_mock_server.decorators import MockVWS +from mock_vws._requests_mock_server.decorators import ( + MissingSchemeError, + MockVWS, +) __all__ = [ + "MissingSchemeError", "MockVWS", ] diff --git a/src/mock_vws/_base64_decoding.py b/src/mock_vws/_base64_decoding.py index 03dfe09e5..0d9ae1159 100644 --- a/src/mock_vws/_base64_decoding.py +++ b/src/mock_vws/_base64_decoding.py @@ -11,8 +11,7 @@ @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 diff --git a/src/mock_vws/_constants.py b/src/mock_vws/_constants.py index dc964520d..61443bbc7 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -2,15 +2,15 @@ Constants used to make the VWS mock. """ -from enum import Enum +from enum import Enum, unique from beartype import beartype @beartype +@unique class ResultCodes(Enum): - """ - Constants representing various VWS result codes. + """Constants representing various VWS result codes. See https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. @@ -33,6 +33,8 @@ class ResultCodes(Enum): 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" @@ -41,9 +43,9 @@ class ResultCodes(Enum): @beartype +@unique class TargetStatuses(Enum): - """ - Constants representing VWS target statuses. + """Constants representing VWS target statuses. See the 'status' field in https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record diff --git a/src/mock_vws/_database_matchers.py b/src/mock_vws/_database_matchers.py index 691ca4ecb..211e4c70f 100644 --- a/src/mock_vws/_database_matchers.py +++ b/src/mock_vws/_database_matchers.py @@ -19,8 +19,7 @@ def get_database_matching_client_keys( request_path: str, databases: Iterable[VuforiaDatabase], ) -> VuforiaDatabase: - """ - Return the first of the given databases which is being accessed by the + """Return the first of the given databases which is being accessed by the given client request. Args: @@ -36,9 +35,12 @@ def get_database_matching_client_keys( Raises: ValueError: No database matches the given request. """ - content_type = request_headers.get("Content-Type", "").split(sep=";")[0] - auth_header = request_headers.get("Authorization") - 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( @@ -65,8 +67,7 @@ def get_database_matching_server_keys( request_path: str, databases: Iterable[VuforiaDatabase], ) -> VuforiaDatabase: - """ - Return the first of the given databases which is being accessed by the + """Return the first of the given databases which is being accessed by the given server request. Args: @@ -82,9 +83,11 @@ def get_database_matching_server_keys( Raises: ValueError: No database matches the given request. """ - content_type = request_headers.get("Content-Type", "").split(sep=";")[0] - auth_header = request_headers.get("Authorization") - 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( diff --git a/src/mock_vws/_flask_server/Dockerfile b/src/mock_vws/_flask_server/Dockerfile index 19d145087..a08e67063 100644 --- a/src/mock_vws/_flask_server/Dockerfile +++ b/src/mock_vws/_flask_server/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim AS base +FROM python:3.13-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. diff --git a/src/mock_vws/_flask_server/__init__.py b/src/mock_vws/_flask_server/__init__.py index 81533727f..50e18f288 100644 --- a/src/mock_vws/_flask_server/__init__.py +++ b/src/mock_vws/_flask_server/__init__.py @@ -1 +1,3 @@ -"""Flask server for the mock Vuforia web service.""" +""" +Flask server for the mock Vuforia web service. +""" diff --git a/src/mock_vws/_flask_server/healthcheck.py b/src/mock_vws/_flask_server/healthcheck.py index a20f3a8cc..39c37a5e2 100644 --- a/src/mock_vws/_flask_server/healthcheck.py +++ b/src/mock_vws/_flask_server/healthcheck.py @@ -15,9 +15,9 @@ def flask_app_healthy(port: int) -> bool: """ Check if the Flask app is healthy. """ - conn = http.client.HTTPConnection("localhost", port) + conn = http.client.HTTPConnection(host="localhost", port=port) try: - conn.request("GET", "/some-random-endpoint") + conn.request(method="GET", url="/some-random-endpoint") response = conn.getresponse() except (TimeoutError, http.client.HTTPException, socket.gaierror): return False diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 83afda030..3e97b86d4 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -3,7 +3,7 @@ """ import base64 -import dataclasses +import copy import datetime import json from enum import StrEnum, auto @@ -32,39 +32,46 @@ @beartype class _TargetRaterChoice(StrEnum): - """Target rater choices.""" + """ + Target rater choices. + """ BRISQUE = auto() PERFECT = auto() RANDOM = auto() def to_target_rater(self) -> TargetTrackingRater: - """Get the target rater.""" - rater = { - _TargetRaterChoice.BRISQUE: BrisqueTargetTrackingRater(), - _TargetRaterChoice.PERFECT: HardcodedTargetTrackingRater(rating=5), - _TargetRaterChoice.RANDOM: RandomTargetTrackingRater(), - }[self] - assert isinstance(rater, TargetTrackingRater) - return rater + """ + Get the target rater. + """ + match self: + case self.BRISQUE: + return BrisqueTargetTrackingRater() + case self.PERFECT: + return HardcodedTargetTrackingRater(rating=5) + case self.RANDOM: + return RandomTargetTrackingRater() + + raise ValueError # pragma: no cover @beartype class TargetManagerSettings(BaseSettings): - """Settings for the Target Manager Flask app.""" + """ + Settings for the Target Manager Flask app. + """ target_manager_host: str = "" target_rater: _TargetRaterChoice = _TargetRaterChoice.BRISQUE @TARGET_MANAGER_FLASK_APP.route( - "/databases/", + rule="/databases/", methods=[HTTPMethod.DELETE], ) @beartype def delete_database(database_name: str) -> Response: - """ - Delete a database. + """Delete a database. :status 200: The database has been deleted. """ @@ -81,7 +88,7 @@ def delete_database(database_name: str) -> Response: return Response(response="", status=HTTPStatus.OK) -@TARGET_MANAGER_FLASK_APP.route("/databases", methods=[HTTPMethod.GET]) +@TARGET_MANAGER_FLASK_APP.route(rule="/databases", methods=[HTTPMethod.GET]) @beartype def get_databases() -> Response: """ @@ -94,34 +101,44 @@ def get_databases() -> Response: ) -@TARGET_MANAGER_FLASK_APP.route("/databases", methods=[HTTPMethod.POST]) +@TARGET_MANAGER_FLASK_APP.route(rule="/databases", methods=[HTTPMethod.POST]) @beartype def create_database() -> Response: - """ - Create a new database. + """Create a new database. :reqheader Content-Type: application/json :resheader Content-Type: application/json :reqjson string client_access_key: (Optional) The client access key for the database. + :reqjson string client_secret_key: (Optional) The client secret key for the database. + :reqjson string database_name: (Optional) The name of the database. + :reqjson string server_access_key: (Optional) The server access key for the database. + :reqjson string server_secret_key: (Optional) The server secret key for the 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. @@ -167,7 +184,7 @@ def create_database() -> Response: TARGET_MANAGER.add_database(database=database) except ValueError as exc: return Response( - response=str(exc), + response=str(object=exc), status=HTTPStatus.CONFLICT, ) @@ -178,7 +195,7 @@ def create_database() -> Response: @TARGET_MANAGER_FLASK_APP.route( - "/databases//targets", + rule="/databases//targets", methods=[HTTPMethod.POST], ) @beartype @@ -216,7 +233,7 @@ def create_target(database_name: str) -> Response: @TARGET_MANAGER_FLASK_APP.route( - "/databases//targets/", + rule="/databases//targets/", methods={HTTPMethod.DELETE}, ) @beartype @@ -231,7 +248,7 @@ def delete_target(database_name: str, target_id: str) -> Response: ) 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) + new_target = copy.replace(target, delete_date=now) database.targets.remove(target) database.targets.add(new_target) return Response( @@ -241,7 +258,7 @@ def delete_target(database_name: str, target_id: str) -> Response: @TARGET_MANAGER_FLASK_APP.route( - "/databases//targets/", + rule="/databases//targets/", methods=[HTTPMethod.PUT], ) def update_target(database_name: str, target_id: str) -> Response: @@ -272,7 +289,7 @@ def update_target(database_name: str, target_id: str) -> Response: gmt = ZoneInfo(key="GMT") last_modified_date = datetime.datetime.now(tz=gmt) - new_target = dataclasses.replace( + new_target = copy.replace( target, name=name, width=width, diff --git a/src/mock_vws/_flask_server/vwq.py b/src/mock_vws/_flask_server/vwq.py index f0b7394c2..dee12db42 100644 --- a/src/mock_vws/_flask_server/vwq.py +++ b/src/mock_vws/_flask_server/vwq.py @@ -1,5 +1,4 @@ -""" -A fake implementation of the Vuforia Web Query API using Flask. +"""A fake implementation of the Vuforia Web Query API using Flask. See https://developer.vuforia.com/library/web-api/vuforia-query-web-api @@ -34,25 +33,31 @@ @beartype class _ImageMatcherChoice(StrEnum): - """Image matcher choices.""" + """ + Image matcher choices. + """ EXACT = auto() STRUCTURAL_SIMILARITY = auto() def to_image_matcher(self) -> ImageMatcher: - """Get the image matcher.""" - ssim_matcher = StructuralSimilarityMatcher() - matcher = { - _ImageMatcherChoice.EXACT: ExactMatcher(), - _ImageMatcherChoice.STRUCTURAL_SIMILARITY: ssim_matcher, - }[self] - assert isinstance(matcher, ImageMatcher) - return matcher + """ + Get the image matcher. + """ + match self: + case self.EXACT: + return ExactMatcher() + case self.STRUCTURAL_SIMILARITY: + return StructuralSimilarityMatcher() + + raise ValueError # pragma: no cover @beartype class VWQSettings(BaseSettings): - """Settings for the VWQ Flask app.""" + """ + Settings for the VWQ Flask app. + """ vwq_host: str = "" target_manager_base_url: str @@ -80,10 +85,9 @@ def get_all_databases() -> set[VuforiaDatabase]: @CLOUDRECO_FLASK_APP.before_request @beartype def set_terminate_wsgi_input() -> None: - """ - We set ``wsgi.input_terminated`` to ``True`` when going through - ``requests`` in our tests, so that requests have the given - ``Content-Length`` headers and the given data in ``request.headers`` and + """We set ``wsgi.input_terminated`` to ``True`` when going through + ``requests`` in our tests, so that requests have the given ``Content- + Length`` headers and the given data in ``request.headers`` and ``request.data``. We do not set this at all when running an application as standalone. @@ -105,7 +109,7 @@ def set_terminate_wsgi_input() -> None: request.environ["wsgi.input_terminated"] = True -@CLOUDRECO_FLASK_APP.errorhandler(ValidatorError) +@CLOUDRECO_FLASK_APP.errorhandler(code_or_exception=ValidatorError) def handle_exceptions(exc: ValidatorError) -> Response: """ Return the error response associated with the given exception. @@ -121,7 +125,7 @@ def handle_exceptions(exc: ValidatorError) -> Response: return response -@CLOUDRECO_FLASK_APP.route("/v1/query", methods=[HTTPMethod.POST]) +@CLOUDRECO_FLASK_APP.route(rule="/v1/query", methods=[HTTPMethod.POST]) def query() -> Response: """ Perform an image recognition query. diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index d5913f479..2d2f9ac9f 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -1,5 +1,4 @@ -""" -A fake implementation of the Vuforia Web Services API. +"""A fake implementation of the Vuforia Web Services API. See https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api @@ -48,25 +47,31 @@ @beartype class _ImageMatcherChoice(StrEnum): - """Image matcher choices.""" + """ + Image matcher choices. + """ EXACT = auto() STRUCTURAL_SIMILARITY = auto() def to_image_matcher(self) -> ImageMatcher: - """Get the image matcher.""" - ssim_matcher = StructuralSimilarityMatcher() - matcher = { - _ImageMatcherChoice.EXACT: ExactMatcher(), - _ImageMatcherChoice.STRUCTURAL_SIMILARITY: ssim_matcher, - }[self] - assert isinstance(matcher, ImageMatcher) - return matcher + """ + Get the image matcher. + """ + match self: + case self.EXACT: + return ExactMatcher() + case self.STRUCTURAL_SIMILARITY: + return StructuralSimilarityMatcher() + + raise ValueError # pragma: no cover @beartype class VWSSettings(BaseSettings): - """Settings for the VWS Flask app.""" + """ + Settings for the VWS Flask app. + """ target_manager_base_url: str processing_time_seconds: float = 2.0 @@ -95,10 +100,9 @@ def get_all_databases() -> set[VuforiaDatabase]: @VWS_FLASK_APP.before_request def set_terminate_wsgi_input() -> None: - """ - We set ``wsgi.input_terminated`` to ``True`` when going through - ``requests`` in our tests, so that requests have the given - ``Content-Length`` headers and the given data in ``request.headers`` and + """We set ``wsgi.input_terminated`` to ``True`` when going through + ``requests`` in our tests, so that requests have the given ``Content- + Length`` headers and the given data in ``request.headers`` and ``request.data``. We do not set this at all when running an application as standalone. @@ -136,7 +140,7 @@ def validate_request() -> None: ) -@VWS_FLASK_APP.errorhandler(ValidatorError) +@VWS_FLASK_APP.errorhandler(code_or_exception=ValidatorError) def handle_exceptions(exc: ValidatorError) -> Response: """ Return the error response associated with the given exception. @@ -152,11 +156,10 @@ def handle_exceptions(exc: ValidatorError) -> Response: return response -@VWS_FLASK_APP.route("/targets", methods=[HTTPMethod.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://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add @@ -225,11 +228,12 @@ def add_target() -> Response: ) -@VWS_FLASK_APP.route("/targets/", methods=[HTTPMethod.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://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record @@ -281,12 +285,11 @@ def get_target(target_id: str) -> Response: @VWS_FLASK_APP.route( - "/targets/", + rule="/targets/", methods=[HTTPMethod.DELETE], ) def delete_target(target_id: str) -> Response: - """ - Delete a target. + """Delete a target. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete @@ -336,11 +339,10 @@ def delete_target(target_id: str) -> Response: ) -@VWS_FLASK_APP.route("/summary", methods=[HTTPMethod.GET]) +@VWS_FLASK_APP.route(rule="/summary", methods=[HTTPMethod.GET]) @beartype def database_summary() -> Response: - """ - Get a database summary report. + """Get a database summary report. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report @@ -390,10 +392,12 @@ def database_summary() -> Response: ) -@VWS_FLASK_APP.route("/summary/", methods=[HTTPMethod.GET]) +@VWS_FLASK_APP.route( + rule="/summary/", + methods=[HTTPMethod.GET], +) 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://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#retrieve-report @@ -416,7 +420,7 @@ def target_summary(target_id: str) -> Response: "result_code": ResultCodes.SUCCESS.value, "database_name": database.database_name, "target_name": target.name, - "upload_date": target.upload_date.strftime("%Y-%m-%d"), + "upload_date": target.upload_date.strftime(format="%Y-%m-%d"), "active_flag": target.active_flag, "tracking_rating": target.tracking_rating, "total_recos": target.total_recos, @@ -442,13 +446,12 @@ def target_summary(target_id: str) -> Response: @VWS_FLASK_APP.route( - "/duplicates/", + 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://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check @@ -469,7 +472,7 @@ def get_duplicates(target_id: str) -> Response: ) other_targets = database.targets - {target} - similar_targets: list[str] = [ + similar_targets = [ other.target_id for other in other_targets if image_match_checker( @@ -505,10 +508,9 @@ def get_duplicates(target_id: str) -> Response: ) -@VWS_FLASK_APP.route("/targets", methods=[HTTPMethod.GET]) +@VWS_FLASK_APP.route(rule="/targets", methods=[HTTPMethod.GET]) def target_list() -> Response: - """ - Get a list of all targets. + """Get a list of all targets. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list @@ -546,10 +548,11 @@ def target_list() -> Response: ) -@VWS_FLASK_APP.route("/targets/", methods=[HTTPMethod.PUT]) +@VWS_FLASK_APP.route( + rule="/targets/", methods=[HTTPMethod.PUT] +) def update_target(target_id: str) -> Response: - """ - Update a target. + """Update a target. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update diff --git a/src/mock_vws/_mock_common.py b/src/mock_vws/_mock_common.py index c60273676..7ef5d7502 100644 --- a/src/mock_vws/_mock_common.py +++ b/src/mock_vws/_mock_common.py @@ -3,6 +3,7 @@ """ import json +from collections.abc import Iterable from dataclasses import dataclass from typing import Any @@ -11,8 +12,7 @@ @dataclass(frozen=True) class Route: - """ - A representation of a VWS route. + """A representation of a VWS route. Args: route_name: The name of the method. @@ -23,7 +23,7 @@ class Route: route_name: str path_pattern: str - http_methods: frozenset[str] + http_methods: Iterable[str] @beartype diff --git a/src/mock_vws/_query_tools.py b/src/mock_vws/_query_tools.py index f5acdb014..520afd9ab 100644 --- a/src/mock_vws/_query_tools.py +++ b/src/mock_vws/_query_tools.py @@ -5,7 +5,7 @@ import base64 import io import uuid -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from email.message import EmailMessage from typing import Any @@ -27,7 +27,7 @@ def get_query_match_response_text( request_body: bytes, request_method: str, request_path: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], query_match_checker: ImageMatcher, ) -> str: """ @@ -50,12 +50,15 @@ def get_query_match_response_text( parser = MultiPartParser() fields, files = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) - max_num_results = fields.get("max_num_results", "1") - include_target_data = fields.get("include_target_data", "top").lower() + max_num_results = fields.get(key="max_num_results", default="1") + include_target_data = fields.get( + key="include_target_data", + default="top", + ).lower() image_part = files["image"] image_value = image_part.stream.read() @@ -103,8 +106,8 @@ def get_query_match_response_text( 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, diff --git a/src/mock_vws/_query_validators/__init__.py b/src/mock_vws/_query_validators/__init__.py index 411bdd995..9fc608c78 100644 --- a/src/mock_vws/_query_validators/__init__.py +++ b/src/mock_vws/_query_validators/__init__.py @@ -2,7 +2,7 @@ Input validators to use in the mock query API. """ -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from beartype import beartype @@ -47,10 +47,9 @@ def run_query_validators( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> None: - """ - Run all validators. + """Run all validators. Args: request_path: The path of the request. diff --git a/src/mock_vws/_query_validators/accept_header_validators.py b/src/mock_vws/_query_validators/accept_header_validators.py index f7aa766a3..baeb735fe 100644 --- a/src/mock_vws/_query_validators/accept_header_validators.py +++ b/src/mock_vws/_query_validators/accept_header_validators.py @@ -14,8 +14,7 @@ @beartype def validate_accept_header(request_headers: Mapping[str, str]) -> None: - """ - Validate the accept header. + """Validate the accept header. Args: request_headers: The headers sent with the request. diff --git a/src/mock_vws/_query_validators/auth_validators.py b/src/mock_vws/_query_validators/auth_validators.py index 054a82a78..a8a139691 100644 --- a/src/mock_vws/_query_validators/auth_validators.py +++ b/src/mock_vws/_query_validators/auth_validators.py @@ -3,7 +3,7 @@ """ import logging -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from beartype import beartype @@ -20,8 +20,8 @@ @beartype def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: - """ - Validate that there is an authorization header given to the query endpoint. + """Validate that there is an authorization header given to the query + endpoint. Args: request_headers: The headers sent with the request. @@ -41,8 +41,7 @@ def validate_auth_header_number_of_parts( *, 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. @@ -65,10 +64,9 @@ def validate_auth_header_number_of_parts( def validate_client_key_exists( *, request_headers: Mapping[str, str], - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> 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. @@ -92,8 +90,7 @@ def validate_client_key_exists( def validate_auth_header_has_signature( 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. @@ -116,10 +113,9 @@ def validate_authorization( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> 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. diff --git a/src/mock_vws/_query_validators/content_length_validators.py b/src/mock_vws/_query_validators/content_length_validators.py index 4abbf6bf0..f7cbac25b 100644 --- a/src/mock_vws/_query_validators/content_length_validators.py +++ b/src/mock_vws/_query_validators/content_length_validators.py @@ -21,8 +21,7 @@ def validate_content_length_header_is_int( *, 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. @@ -46,8 +45,7 @@ def validate_content_length_header_not_too_large( 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. @@ -73,8 +71,7 @@ def validate_content_length_header_not_too_small( 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. diff --git a/src/mock_vws/_query_validators/content_type_validators.py b/src/mock_vws/_query_validators/content_type_validators.py index ce0450bca..bd474c6ab 100644 --- a/src/mock_vws/_query_validators/content_type_validators.py +++ b/src/mock_vws/_query_validators/content_type_validators.py @@ -23,8 +23,7 @@ def validate_content_type_header( 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. @@ -39,7 +38,8 @@ def validate_content_type_header( NoContentTypeError: The content type header is either empty or not given. """ - content_type_header = request_headers.get("Content-Type", "") + 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 diff --git a/src/mock_vws/_query_validators/date_validators.py b/src/mock_vws/_query_validators/date_validators.py index 755200b3f..d02e44aaa 100644 --- a/src/mock_vws/_query_validators/date_validators.py +++ b/src/mock_vws/_query_validators/date_validators.py @@ -21,8 +21,7 @@ @beartype def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: - """ - Validate the date header is given to the query endpoint. + """Validate the date header is given to the query endpoint. Args: request_headers: The headers sent with the request. @@ -38,11 +37,10 @@ def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: def _accepted_date_formats() -> set[str]: - """ - Return all known accepted date formats. + """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", @@ -58,8 +56,7 @@ def _accepted_date_formats() -> set[str]: @beartype def validate_date_format(*, request_headers: Mapping[str, str]) -> None: - """ - Validate the format of the date header given to the query endpoint. + """Validate the format of the date header given to the query endpoint. Args: request_headers: The headers sent with the request. @@ -80,8 +77,7 @@ def validate_date_format(*, request_headers: Mapping[str, str]) -> None: @beartype def validate_date_in_range(*, request_headers: Mapping[str, str]) -> None: - """ - Validate date in the date header given to the query endpoint. + """Validate date in the date header given to the query endpoint. Args: request_headers: The headers sent with the request. @@ -92,7 +88,7 @@ def validate_date_in_range(*, request_headers: Mapping[str, str]) -> None: date_header = request_headers["Date"] gmt = ZoneInfo(key="GMT") - date = datetime.datetime.fromtimestamp(0, tz=gmt) + date = datetime.datetime.fromtimestamp(timestamp=0, tz=gmt) for date_format in _accepted_date_formats(): with contextlib.suppress(ValueError): date = datetime.datetime.strptime( diff --git a/src/mock_vws/_query_validators/exceptions.py b/src/mock_vws/_query_validators/exceptions.py index 8a39d468b..87b87390a 100644 --- a/src/mock_vws/_query_validators/exceptions.py +++ b/src/mock_vws/_query_validators/exceptions.py @@ -5,6 +5,7 @@ import email.utils import textwrap import uuid +from collections.abc import Mapping from http import HTTPStatus from beartype import beartype @@ -22,7 +23,7 @@ class ValidatorError(Exception): status_code: HTTPStatus response_text: str - headers: dict[str, str] + headers: Mapping[str, str] @beartype @@ -52,7 +53,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -84,7 +85,7 @@ def __init__(self) -> None: "Server": "nginx", "Date": date, "WWW-Authenticate": "KWS", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -120,7 +121,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -163,7 +164,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -206,7 +207,7 @@ def __init__(self) -> None: "Server": "nginx", "Date": date, "WWW-Authenticate": "VWS", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -244,7 +245,7 @@ def __init__(self) -> None: "Server": "nginx", "Date": date, "WWW-Authenticate": "VWS", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -276,7 +277,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -309,7 +310,7 @@ def __init__(self) -> None: "Server": "nginx", "Date": date, "WWW-Authenticate": "KWS", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -343,7 +344,7 @@ def __init__(self) -> None: "Server": "nginx", "Date": date, "WWW-Authenticate": "KWS", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -375,7 +376,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -417,15 +418,15 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @beartype class InvalidMaxNumResultsError(ValidatorError): """ - Exception raised when an invalid value is given as the - "max_num_results" field. + Exception raised when an invalid value is given as the "max_num_results" + field. """ def __init__(self, given_value: str) -> None: @@ -454,7 +455,7 @@ def __init__(self, given_value: str) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -491,7 +492,7 @@ def __init__(self, given_value: str) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -530,7 +531,7 @@ def __init__(self, given_value: str) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -561,7 +562,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -592,7 +593,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -627,7 +628,7 @@ def __init__(self) -> None: "Connection": "keep-alive", "Server": "nginx", "Date": date, - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -651,7 +652,7 @@ def __init__(self) -> None: # pragma: no cover self.response_text = "" self.headers = { "Connection": "keep-alive", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -674,7 +675,7 @@ def __init__(self) -> None: self.response_text = "" self.headers = { "Connection": "Close", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -718,7 +719,7 @@ def __init__(self) -> None: # pragma: no cover "Date": date, "Server": "nginx", "Content-Type": "text/html", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -770,5 +771,5 @@ def __init__(self) -> None: "Server": "nginx", "Cache-Control": "must-revalidate,no-cache,no-store", "Date": date, - "Content-Length": str(len(self.response_text)), + "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 2f281aca5..3b91fb9fb 100644 --- a/src/mock_vws/_query_validators/fields_validators.py +++ b/src/mock_vws/_query_validators/fields_validators.py @@ -21,8 +21,7 @@ def validate_extra_fields( 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. @@ -37,7 +36,7 @@ def validate_extra_fields( parser = MultiPartParser() fields, files = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) parsed_keys = fields.keys() | files.keys() diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index 7c5d2e1d8..203c606de 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -26,8 +26,7 @@ def validate_image_field_given( 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. @@ -42,10 +41,10 @@ def validate_image_field_given( parser = MultiPartParser() _, files = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) - if files.get("image") is not None: + if files.get(key="image") is not None: return _LOGGER.warning(msg="The image field is not given.") @@ -58,8 +57,7 @@ def validate_image_file_size( 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. @@ -74,7 +72,7 @@ def validate_image_file_size( parser = MultiPartParser() _, files = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) image_part = files["image"] @@ -99,8 +97,7 @@ def validate_image_dimensions( 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. @@ -116,7 +113,7 @@ def validate_image_dimensions( parser = MultiPartParser() _, files = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) image_part = files["image"] @@ -138,8 +135,7 @@ def validate_image_format( 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. @@ -154,7 +150,7 @@ def validate_image_format( parser = MultiPartParser() _, files = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) image_part = files["image"] @@ -172,8 +168,7 @@ def validate_image_is_image( 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. @@ -188,7 +183,7 @@ def validate_image_is_image( parser = MultiPartParser() _, files = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) image_part = files["image"] 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 b89fd0775..5f8277ade 100644 --- a/src/mock_vws/_query_validators/include_target_data_validators.py +++ b/src/mock_vws/_query_validators/include_target_data_validators.py @@ -20,9 +20,8 @@ def validate_include_target_data( 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. @@ -38,10 +37,10 @@ def validate_include_target_data( parser = MultiPartParser() fields, _ = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) - include_target_data = fields.get("include_target_data", "top") + 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 diff --git a/src/mock_vws/_query_validators/num_results_validators.py b/src/mock_vws/_query_validators/num_results_validators.py index 65705ae33..ff4b99ae1 100644 --- a/src/mock_vws/_query_validators/num_results_validators.py +++ b/src/mock_vws/_query_validators/num_results_validators.py @@ -24,9 +24,8 @@ def validate_max_num_results( 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. @@ -44,10 +43,10 @@ def validate_max_num_results( parser = MultiPartParser() fields, _ = parser.parse( stream=io.BytesIO(initial_bytes=request_body), - boundary=boundary.encode("utf-8"), + boundary=boundary.encode(encoding="utf-8"), content_length=len(request_body), ) - max_num_results = fields.get("max_num_results", "1") + max_num_results = fields.get(key="max_num_results", default="1") try: max_num_results_int = int(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 dccf202fc..bd44f4fd8 100644 --- a/src/mock_vws/_query_validators/project_state_validators.py +++ b/src/mock_vws/_query_validators/project_state_validators.py @@ -3,7 +3,7 @@ """ import logging -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from beartype import beartype @@ -21,10 +21,9 @@ def validate_project_state( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> None: - """ - Validate the state of the project. + """Validate the state of the project. Args: request_path: The path of the request. diff --git a/src/mock_vws/_requests_mock_server/decorators.py b/src/mock_vws/_requests_mock_server/decorators.py index 69f6c1497..6acdd899d 100644 --- a/src/mock_vws/_requests_mock_server/decorators.py +++ b/src/mock_vws/_requests_mock_server/decorators.py @@ -4,10 +4,9 @@ import re from contextlib import ContextDecorator -from typing import Literal, Self +from typing import TYPE_CHECKING, Literal, Self from urllib.parse import urljoin, urlparse -import requests from beartype import BeartypeConf, beartype from responses import RequestsMock @@ -25,10 +24,36 @@ from .mock_web_query_api import MockVuforiaWebQueryAPI from .mock_web_services_api import MockVuforiaWebServicesAPI +if TYPE_CHECKING: + from collections.abc import Iterable + _STRUCTURAL_SIMILARITY_MATCHER = StructuralSimilarityMatcher() _BRISQUE_TRACKING_RATER = BrisqueTargetTrackingRater() +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(conf=BeartypeConf(is_pep484_tower=True)) class MockVWS(ContextDecorator): """ @@ -37,17 +62,16 @@ class MockVWS(ContextDecorator): def __init__( self, + *, 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, ) -> 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. Args: real_http: Whether or not to forward requests to the real @@ -66,8 +90,7 @@ def __init__( target_tracking_rater: A callable for rating targets for tracking. 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 @@ -76,15 +99,10 @@ def __init__( 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): parse_result = urlparse(url=url) if not parse_result.scheme: - error = missing_scheme_error.format(url=url) - raise requests.exceptions.MissingSchema(error) + raise MissingSchemeError(url=url) self._mock_vws_api = MockVuforiaWebServicesAPI( target_manager=self._target_manager, @@ -99,8 +117,7 @@ def __init__( ) def add_database(self, database: VuforiaDatabase) -> None: - """ - Add a cloud database. + """Add a cloud database. Args: database: The database to add. @@ -112,13 +129,12 @@ def add_database(self, database: VuforiaDatabase) -> None: self._target_manager.add_database(database=database) def __enter__(self) -> Self: - """ - Start an instance of a Vuforia mock. + """Start an instance of a Vuforia mock. Returns: ``self``. """ - compiled_url_patterns: set[re.Pattern[str]] = set() + compiled_url_patterns: Iterable[re.Pattern[str]] = set() mock = RequestsMock(assert_all_requests_are_fired=False) for vws_route in self._mock_vws_api.routes: @@ -127,7 +143,10 @@ def __enter__(self) -> Self: url=f"{vws_route.path_pattern}$", ) compiled_url_pattern = re.compile(pattern=url_pattern) - compiled_url_patterns.add(compiled_url_pattern) + compiled_url_patterns = { + *compiled_url_patterns, + compiled_url_pattern, + } for vws_http_method in vws_route.http_methods: mock.add_callback( @@ -143,7 +162,10 @@ def __enter__(self) -> Self: url=f"{vwq_route.path_pattern}$", ) compiled_url_pattern = re.compile(pattern=url_pattern) - compiled_url_patterns.add(compiled_url_pattern) + compiled_url_patterns = { + *compiled_url_patterns, + compiled_url_pattern, + } for vwq_http_method in vwq_route.http_methods: mock.add_callback( @@ -163,15 +185,14 @@ def __enter__(self) -> Self: return self def __exit__(self, *exc: object) -> Literal[False]: - """ - Stop the Vuforia mock. + """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. - assert isinstance(exc, tuple) + del exc self._mock.stop() return False diff --git a/src/mock_vws/_requests_mock_server/mock_web_query_api.py b/src/mock_vws/_requests_mock_server/mock_web_query_api.py index cc6871b80..f22cd9c69 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,12 +1,11 @@ -""" -A fake implementation of the Vuforia Web Query API. +"""A fake implementation of the Vuforia Web Query API. See https://developer.vuforia.com/library/web-api/vuforia-query-web-api """ import email.utils -from collections.abc import Callable +from collections.abc import Callable, Iterable, Mapping from http import HTTPMethod, HTTPStatus from beartype import beartype @@ -25,16 +24,15 @@ _ROUTES: set[Route] = set() -_ResponseType = tuple[int, dict[str, str], str] +_ResponseType = tuple[int, Mapping[str, str], str] @beartype def route( path_pattern: str, - http_methods: set[str], + http_methods: Iterable[str], ) -> Callable[[Callable[..., _ResponseType]], Callable[..., _ResponseType]]: - """ - Register a decorated method so that it can be recognized as a route. + """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 @@ -48,20 +46,18 @@ def route( def decorator( method: Callable[..., _ResponseType], ) -> Callable[..., _ResponseType]: - """ - Register a decorated method so that it can be recognized as a route. + """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 @@ -73,17 +69,15 @@ def _body_bytes(request: PreparedRequest) -> bytes: """ Return the body of a request as bytes. """ - if request.body is None: + if request.body is None or isinstance(request.body, str): return b"" - assert isinstance(request.body, bytes) return request.body @beartype class MockVuforiaWebQueryAPI: - """ - A fake implementation of the Vuforia Web Query API. + """A fake implementation of the Vuforia Web Query API. This implementation is tied to the implementation of ``responses``. """ @@ -102,7 +96,7 @@ def __init__( 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_match_checker = query_match_checker @@ -141,6 +135,6 @@ def query(self, request: PreparedRequest) -> _ResponseType: "Content-Type": "application/json", "Server": "nginx", "Date": date, - "Content-Length": str(len(response_text)), + "Content-Length": str(object=len(response_text)), } return HTTPStatus.OK, headers, response_text diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 36936c034..9a6b30a8d 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,17 +1,16 @@ -""" -A fake implementation of the Vuforia Web Services API. +"""A fake implementation of the Vuforia Web Services API. See https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api """ import base64 -import dataclasses +import copy import datetime import email.utils import json import uuid -from collections.abc import Callable +from collections.abc import Callable, Iterable, Mapping from http import HTTPMethod, HTTPStatus from typing import Any from zoneinfo import ZoneInfo @@ -39,16 +38,15 @@ _ROUTES: set[Route] = set() -_ResponseType = tuple[int, dict[str, str], str] +_ResponseType = tuple[int, Mapping[str, str], str] @beartype def route( path_pattern: str, - http_methods: set[HTTPMethod], + http_methods: Iterable[HTTPMethod], ) -> Callable[[Callable[..., _ResponseType]], Callable[..., _ResponseType]]: - """ - Register a decorated method so that it can be recognized as a route. + """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 @@ -63,20 +61,18 @@ def route( def decorator( method: Callable[..., _ResponseType], ) -> Callable[..., _ResponseType]: - """ - Register a decorated method so that it can be recognized as a route. + """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 @@ -94,14 +90,12 @@ def _body_bytes(request: PreparedRequest) -> bytes: if isinstance(request.body, str): return request.body.encode(encoding="utf-8") - assert isinstance(request.body, bytes) return request.body @beartype(conf=BeartypeConf(is_pep484_tower=True)) class MockVuforiaWebServicesAPI: - """ - A fake implementation of the Vuforia Web Services API. + """A fake implementation of the Vuforia Web Services API. This implementation is tied to the implementation of ``responses``. """ @@ -128,7 +122,7 @@ def __init__( 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 @@ -138,8 +132,7 @@ def __init__( http_methods={HTTPMethod.POST}, ) def add_target(self, request: PreparedRequest) -> _ResponseType: - """ - Add a target. + """Add a target. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add @@ -201,7 +194,7 @@ def add_target(self, request: PreparedRequest) -> _ResponseType: "Content-Type": "application/json", "server": "envoy", "Date": date, - "Content-Length": str(len(body_json)), + "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", @@ -214,8 +207,7 @@ def add_target(self, request: PreparedRequest) -> _ResponseType: http_methods={HTTPMethod.DELETE}, ) def delete_target(self, request: PreparedRequest) -> _ResponseType: - """ - Delete a target. + """Delete a target. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete @@ -231,7 +223,6 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: 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=_body_bytes(request=request), @@ -252,7 +243,7 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: ) now = datetime.datetime.now(tz=target.upload_date.tzinfo) - new_target = dataclasses.replace(target, delete_date=now) + new_target = copy.replace(target, delete_date=now) database.targets.remove(target) database.targets.add(new_target) date = email.utils.formatdate( @@ -268,7 +259,7 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: body_json = json_dump(body=body) headers = { "Connection": "keep-alive", - "Content-Length": str(len(body_json)), + "Content-Length": str(object=len(body_json)), "Content-Type": "application/json", "Date": date, "server": "envoy", @@ -281,8 +272,7 @@ def delete_target(self, request: PreparedRequest) -> _ResponseType: @route(path_pattern="/summary", http_methods={HTTPMethod.GET}) def database_summary(self, request: PreparedRequest) -> _ResponseType: - """ - Get a database summary report. + """Get a database summary report. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report @@ -298,8 +288,6 @@ def database_summary(self, request: PreparedRequest) -> _ResponseType: except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text - body: dict[str, str | int] = {} - database = get_database_matching_server_keys( request_headers=request.headers, request_body=_body_bytes(request=request), @@ -332,7 +320,7 @@ def database_summary(self, request: PreparedRequest) -> _ResponseType: body_json = json_dump(body=body) headers = { "Connection": "keep-alive", - "Content-Length": str(len(body_json)), + "Content-Length": str(object=len(body_json)), "Content-Type": "application/json", "Date": date, "server": "envoy", @@ -345,8 +333,7 @@ def database_summary(self, request: PreparedRequest) -> _ResponseType: @route(path_pattern="/targets", http_methods={HTTPMethod.GET}) def target_list(self, request: PreparedRequest) -> _ResponseType: - """ - Get a list of all targets. + """Get a list of all targets. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list @@ -379,7 +366,7 @@ def target_list(self, request: PreparedRequest) -> _ResponseType: response_results = [ target.target_id for target in database.not_deleted_targets ] - body: dict[str, str | list[str]] = { + body = { "transaction_id": uuid.uuid4().hex, "result_code": ResultCodes.SUCCESS.value, "results": response_results, @@ -387,7 +374,7 @@ def target_list(self, request: PreparedRequest) -> _ResponseType: body_json = json_dump(body=body) headers = { "Connection": "keep-alive", - "Content-Length": str(len(body_json)), + "Content-Length": str(object=len(body_json)), "Content-Type": "application/json", "Date": date, "server": "envoy", @@ -403,8 +390,7 @@ def target_list(self, request: PreparedRequest) -> _ResponseType: http_methods={HTTPMethod.GET}, ) def get_target(self, request: PreparedRequest) -> _ResponseType: - """ - Get details of a target. + """Get details of a target. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record @@ -453,7 +439,7 @@ def get_target(self, request: PreparedRequest) -> _ResponseType: body_json = json_dump(body=body) headers = { "Connection": "keep-alive", - "Content-Length": str(len(body_json)), + "Content-Length": str(object=len(body_json)), "Content-Type": "application/json", "Date": date, "server": "envoy", @@ -469,8 +455,7 @@ def get_target(self, request: PreparedRequest) -> _ResponseType: http_methods={HTTPMethod.GET}, ) def get_duplicates(self, request: PreparedRequest) -> _ResponseType: - """ - 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://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check @@ -498,7 +483,7 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: other_targets = database.targets - {target} - similar_targets: list[str] = [ + similar_targets = [ other.target_id for other in other_targets if self._duplicate_match_checker( @@ -524,7 +509,7 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: body_json = json_dump(body=body) headers = { "Connection": "keep-alive", - "Content-Length": str(len(body_json)), + "Content-Length": str(object=len(body_json)), "Content-Type": "application/json", "Date": date, "server": "envoy", @@ -541,8 +526,7 @@ def get_duplicates(self, request: PreparedRequest) -> _ResponseType: http_methods={HTTPMethod.PUT}, ) def update_target(self, request: PreparedRequest) -> _ResponseType: - """ - Update a target. + """Update a target. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update @@ -568,7 +552,6 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: target_id = request.path_url.split(sep="/")[-1] target = database.get_target(target_id=target_id) - body: dict[str, str] = {} date = email.utils.formatdate( timeval=None, @@ -619,7 +602,7 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: gmt = ZoneInfo(key="GMT") last_modified_date = datetime.datetime.now(tz=gmt) - new_target = dataclasses.replace( + new_target = copy.replace( target, name=name, width=width, @@ -642,7 +625,7 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: "Content-Type": "application/json", "server": "envoy", "Date": date, - "Content-Length": str(len(body_json)), + "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", @@ -655,8 +638,7 @@ def update_target(self, request: PreparedRequest) -> _ResponseType: http_methods={HTTPMethod.GET}, ) def target_summary(self, request: PreparedRequest) -> _ResponseType: - """ - Get a summary report for a target. + """Get a summary report for a target. Fake implementation of https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#retrieve-report @@ -693,7 +675,7 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: "result_code": ResultCodes.SUCCESS.value, "database_name": database.database_name, "target_name": target.name, - "upload_date": target.upload_date.strftime("%Y-%m-%d"), + "upload_date": target.upload_date.strftime(format="%Y-%m-%d"), "active_flag": target.active_flag, "tracking_rating": target.tracking_rating, "total_recos": target.total_recos, @@ -703,7 +685,7 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: body_json = json_dump(body=body) headers = { "Connection": "keep-alive", - "Content-Length": str(len(body_json)), + "Content-Length": str(object=len(body_json)), "Content-Type": "application/json", "Date": date, "server": "envoy", diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index e49561adf..ac4c0a331 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -2,7 +2,7 @@ Input validators to use in the mock. """ -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from mock_vws.database import VuforiaDatabase @@ -56,10 +56,9 @@ def run_services_validators( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> None: - """ - Run all validators. + """Run all validators. Args: request_path: The path of the request. diff --git a/src/mock_vws/_services_validators/active_flag_validators.py b/src/mock_vws/_services_validators/active_flag_validators.py index a84ec1fc4..f4864bcc7 100644 --- a/src/mock_vws/_services_validators/active_flag_validators.py +++ b/src/mock_vws/_services_validators/active_flag_validators.py @@ -15,8 +15,7 @@ @beartype def validate_active_flag(*, request_body: bytes) -> None: - """ - Validate the active flag data given to the endpoint. + """Validate the active flag data given to the endpoint. Args: request_body: The body of the request. @@ -39,7 +38,7 @@ def validate_active_flag(*, request_body: bytes) -> None: _LOGGER.warning( msg=( - 'The value of "active_flag" is not a Boolean or NULL.' + 'The value of "active_flag" is not a Boolean or NULL. ' "This is not allowed." ), ) diff --git a/src/mock_vws/_services_validators/auth_validators.py b/src/mock_vws/_services_validators/auth_validators.py index e3ec36d23..0fd8d6757 100644 --- a/src/mock_vws/_services_validators/auth_validators.py +++ b/src/mock_vws/_services_validators/auth_validators.py @@ -3,7 +3,7 @@ """ import logging -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from http import HTTPStatus from beartype import beartype @@ -20,8 +20,7 @@ @beartype def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: - """ - Validate that there is an authorization header given to a VWS endpoint. + """Validate that there is an authorization header given to a VWS endpoint. Args: request_headers: The headers sent with the request. @@ -38,10 +37,9 @@ def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: def validate_access_key_exists( *, request_headers: Mapping[str, str], - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> 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. @@ -69,8 +67,7 @@ def validate_auth_header_has_signature( *, 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. @@ -95,10 +92,9 @@ def validate_authorization( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> 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. diff --git a/src/mock_vws/_services_validators/content_length_validators.py b/src/mock_vws/_services_validators/content_length_validators.py index 88f3e451d..9e2cc966b 100644 --- a/src/mock_vws/_services_validators/content_length_validators.py +++ b/src/mock_vws/_services_validators/content_length_validators.py @@ -22,8 +22,7 @@ def validate_content_length_header_is_int( 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. @@ -34,7 +33,11 @@ def validate_content_length_header_is_int( integer """ body_length = len(request_body) - given_content_length = request_headers.get("Content-Length", body_length) + request_headers_dict = dict(request_headers) + given_content_length = request_headers_dict.get( + "Content-Length", + body_length, + ) try: int(given_content_length) @@ -49,8 +52,7 @@ def validate_content_length_header_not_too_large( 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. @@ -61,7 +63,11 @@ def validate_content_length_header_not_too_large( that the content length is greater than the body length. """ body_length = len(request_body) - given_content_length = request_headers.get("Content-Length", body_length) + 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) # We skip coverage here as running a test to cover this is very slow. if given_content_length_value > body_length: # pragma: no cover @@ -75,8 +81,7 @@ def validate_content_length_header_not_too_small( 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. @@ -87,7 +92,11 @@ def validate_content_length_header_not_too_small( the content length is smaller than the body length. """ body_length = len(request_body) - given_content_length = request_headers.get("Content-Length", body_length) + 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: diff --git a/src/mock_vws/_services_validators/content_type_validators.py b/src/mock_vws/_services_validators/content_type_validators.py index 6afa606dc..4c97c4cdf 100644 --- a/src/mock_vws/_services_validators/content_type_validators.py +++ b/src/mock_vws/_services_validators/content_type_validators.py @@ -19,8 +19,8 @@ def validate_content_type_header_given( 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. @@ -30,10 +30,14 @@ def validate_content_type_header_given( AuthenticationFailureError: No ``Content-Type`` header is given and the request requires one. """ + request_headers_dict = dict(request_headers) request_needs_content_type = bool( request_method in {HTTPMethod.POST, HTTPMethod.PUT}, ) - if request_headers.get("Content-Type") or not request_needs_content_type: + if ( + request_headers_dict.get("Content-Type") + or not request_needs_content_type + ): return _LOGGER.warning(msg="No Content-Type header is given.") diff --git a/src/mock_vws/_services_validators/date_validators.py b/src/mock_vws/_services_validators/date_validators.py index b52bf83d7..4f6bda3ed 100644 --- a/src/mock_vws/_services_validators/date_validators.py +++ b/src/mock_vws/_services_validators/date_validators.py @@ -20,8 +20,7 @@ @beartype def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: - """ - Validate the date header is given to a VWS endpoint. + """Validate the date header is given to a VWS endpoint. Args: request_headers: The headers sent with the request. @@ -38,8 +37,7 @@ def validate_date_header_given(*, request_headers: Mapping[str, str]) -> None: @beartype def validate_date_format(*, request_headers: Mapping[str, str]) -> None: - """ - Validate the format of the date header given to a VWS endpoint. + """Validate the format of the date header given to a VWS endpoint. Args: request_headers: The headers sent with the request. @@ -58,8 +56,7 @@ def validate_date_format(*, request_headers: Mapping[str, str]) -> None: @beartype def validate_date_in_range(*, request_headers: Mapping[str, str]) -> None: - """ - Validate the date header given to a VWS endpoint is in range. + """Validate the date header given to a VWS endpoint is in range. Args: request_headers: The headers sent with the request. diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index f528f1bf0..c978ad25d 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -5,6 +5,7 @@ import email.utils import textwrap import uuid +from collections.abc import Mapping from http import HTTPStatus from pathlib import Path @@ -22,7 +23,7 @@ class ValidatorError(Exception): status_code: HTTPStatus response_text: str - headers: dict[str, str] + headers: Mapping[str, str] @beartype @@ -58,7 +59,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -98,7 +99,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -138,7 +139,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -177,7 +178,7 @@ def __init__(self, *, status_code: HTTPStatus) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -217,7 +218,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -257,7 +258,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -266,11 +267,11 @@ def __init__(self) -> None: @beartype class OopsErrorOccurredResponseError(ValidatorError): - """ - Exception raised when VWS returns an HTML page which says "Oops, an error - occurred". + """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. + This has been seen to happen when the given name includes a bad + character. """ def __init__(self) -> None: @@ -286,7 +287,7 @@ def __init__(self) -> None: 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()) + text = str(object=oops_resp_file.read_text()) self.response_text = text date = email.utils.formatdate( timeval=None, @@ -299,7 +300,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -339,7 +340,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -379,7 +380,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -419,7 +420,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -450,7 +451,7 @@ def __init__(self) -> None: # pragma: no cover ) self.response_text = "stream timeout" self.headers = { - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), "Date": date, "server": "envoy", "Content-Type": "text/plain", @@ -491,9 +492,9 @@ def __init__(self) -> None: ) self.headers = { "Connection": "close", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), "Date": date, - "server": "awselb/2.0", + "Server": "awselb/2.0", "Content-Type": "text/html", } @@ -524,7 +525,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "Content-Length": str(object=len(self.response_text)), } @@ -561,7 +562,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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", @@ -600,7 +601,7 @@ def __init__(self) -> None: "server": "envoy", "Date": date, "x-envoy-upstream-service-time": "5", - "Content-Length": str(len(self.response_text)), + "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 8927b7986..96b786f28 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -23,8 +23,7 @@ @beartype def validate_image_format(*, request_body: bytes) -> None: - """ - Validate the format of the image given to a VWS endpoint. + """Validate the format of the image given to a VWS endpoint. Args: request_body: The body of the request. @@ -54,8 +53,7 @@ def validate_image_format(*, request_body: bytes) -> None: @beartype def validate_image_color_space(*, request_body: bytes) -> None: - """ - Validate the color space of the image given to a VWS endpoint. + """Validate the color space of the image given to a VWS endpoint. Args: request_body: The body of the request. @@ -88,8 +86,7 @@ def validate_image_color_space(*, request_body: bytes) -> None: @beartype def validate_image_size(*, request_body: bytes) -> None: - """ - Validate the file size of the image given to a VWS endpoint. + """Validate the file size of the image given to a VWS endpoint. Args: request_body: The body of the request. @@ -119,8 +116,7 @@ def validate_image_size(*, request_body: bytes) -> None: @beartype def validate_image_is_image(*, 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_body: The body of the request. @@ -148,8 +144,7 @@ def validate_image_is_image(*, request_body: bytes) -> None: @beartype def validate_image_encoding(*, request_body: bytes) -> None: - """ - Validate that the given image data can be base64 decoded. + """Validate that the given image data can be base64 decoded. Args: request_body: The body of the request. @@ -175,8 +170,7 @@ def validate_image_encoding(*, request_body: bytes) -> None: @beartype def validate_image_data_type(*, request_body: bytes) -> None: - """ - Validate that the given image data is a string. + """Validate that the given image data is a string. Args: request_body: The body of the request. diff --git a/src/mock_vws/_services_validators/json_validators.py b/src/mock_vws/_services_validators/json_validators.py index 9c9303402..1f97f2784 100644 --- a/src/mock_vws/_services_validators/json_validators.py +++ b/src/mock_vws/_services_validators/json_validators.py @@ -19,9 +19,8 @@ @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. + """Validate that no JSON is given for requests other than ``POST`` and + ``PUT`` requests. Args: request_body: The body of the request. @@ -47,8 +46,7 @@ def validate_body_given(*, request_body: bytes, request_method: str) -> None: @beartype def validate_json(*, request_body: bytes) -> None: - """ - Validate that any given body is valid JSON. + """Validate that any given body is valid JSON. Args: request_body: The body of the request. diff --git a/src/mock_vws/_services_validators/key_validators.py b/src/mock_vws/_services_validators/key_validators.py index 0f823956d..85f4a3f77 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -5,6 +5,7 @@ import json import logging import re +from collections.abc import Iterable from dataclasses import dataclass from http import HTTPMethod, HTTPStatus @@ -17,8 +18,7 @@ @dataclass 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 @@ -30,9 +30,9 @@ class _Route: """ path_pattern: str - http_methods: set[HTTPMethod] - mandatory_keys: set[str] - optional_keys: set[str] + http_methods: Iterable[HTTPMethod] + mandatory_keys: Iterable[str] + optional_keys: Iterable[str] @beartype @@ -42,8 +42,7 @@ def validate_keys( 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. @@ -139,15 +138,15 @@ def validate_keys( route for route in routes if re.match( - pattern=re.compile(f"{route.path_pattern}$"), + pattern=re.compile(pattern=f"{route.path_pattern}$"), string=request_path, ) - and request_method in route.http_methods + 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 @@ -156,7 +155,7 @@ def validate_keys( 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 diff --git a/src/mock_vws/_services_validators/metadata_validators.py b/src/mock_vws/_services_validators/metadata_validators.py index b8b81deb1..7ad3a1851 100644 --- a/src/mock_vws/_services_validators/metadata_validators.py +++ b/src/mock_vws/_services_validators/metadata_validators.py @@ -20,8 +20,7 @@ @beartype def validate_metadata_size(*, request_body: bytes) -> None: - """ - Validate that the given application metadata is a string or 1024 * 1024 + """Validate that the given application metadata is a string or 1024 * 1024 bytes or fewer. Args: @@ -51,8 +50,7 @@ def validate_metadata_size(*, request_body: bytes) -> None: @beartype def validate_metadata_encoding(*, request_body: bytes) -> None: - """ - Validate that the given application metadata can be base64 decoded. + """Validate that the given application metadata can be base64 decoded. Args: request_body: The body of the request. @@ -83,8 +81,7 @@ def validate_metadata_encoding(*, request_body: bytes) -> None: @beartype def validate_metadata_type(*, request_body: bytes) -> None: - """ - Validate that the given application metadata is a string or NULL. + """Validate that the given application metadata is a string or NULL. Args: request_body: The body of the request. diff --git a/src/mock_vws/_services_validators/name_validators.py b/src/mock_vws/_services_validators/name_validators.py index f11de3cd6..2b1c93eb9 100644 --- a/src/mock_vws/_services_validators/name_validators.py +++ b/src/mock_vws/_services_validators/name_validators.py @@ -4,7 +4,7 @@ import json import logging -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from http import HTTPMethod, HTTPStatus from beartype import beartype @@ -27,8 +27,7 @@ def validate_name_characters_in_range( 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. @@ -64,8 +63,7 @@ def validate_name_characters_in_range( @beartype def validate_name_type(*, request_body: bytes) -> None: - """ - Validate the type of the name argument given to a VWS endpoint. + """Validate the type of the name argument given to a VWS endpoint. Args: request_body: The body of the request. @@ -91,8 +89,7 @@ def validate_name_type(*, request_body: bytes) -> None: @beartype def validate_name_length(*, request_body: bytes) -> None: - """ - Validate the length of the name argument given to a VWS endpoint. + """Validate the length of the name argument given to a VWS endpoint. Args: request_body: The body of the request. @@ -121,14 +118,13 @@ def validate_name_length(*, request_body: bytes) -> None: @beartype def validate_name_does_not_exist_new_target( *, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], request_body: bytes, request_headers: Mapping[str, str], request_method: str, request_path: str, ) -> None: - """ - Validate that the name does not exist for any existing target. + """Validate that the name does not exist for any existing target. Args: databases: All Vuforia databases. @@ -182,10 +178,9 @@ def validate_name_does_not_exist_existing_target( request_body: bytes, request_method: str, request_path: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> 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: diff --git a/src/mock_vws/_services_validators/project_state_validators.py b/src/mock_vws/_services_validators/project_state_validators.py index cd21bebee..09fed3d92 100644 --- a/src/mock_vws/_services_validators/project_state_validators.py +++ b/src/mock_vws/_services_validators/project_state_validators.py @@ -3,7 +3,7 @@ """ import logging -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from http import HTTPMethod from beartype import beartype @@ -23,10 +23,9 @@ def validate_project_state( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> None: - """ - Validate the state of the project. + """Validate the state of the project. Args: request_path: The path of the request. diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index 11bb4df9c..1f6a9e0a2 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -3,7 +3,7 @@ """ import logging -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from beartype import beartype @@ -21,11 +21,10 @@ def validate_target_id_exists( request_headers: Mapping[str, str], request_body: bytes, request_method: str, - databases: set[VuforiaDatabase], + databases: Iterable[VuforiaDatabase], ) -> 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. @@ -53,12 +52,11 @@ def validate_target_id_exists( databases=databases, ) - try: - (_,) = ( - target - for target in database.not_deleted_targets - if target.target_id == target_id - ) - except ValueError as 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 from exc + raise UnknownTargetError diff --git a/src/mock_vws/_services_validators/width_validators.py b/src/mock_vws/_services_validators/width_validators.py index 9f42e3c3c..e1d77c596 100644 --- a/src/mock_vws/_services_validators/width_validators.py +++ b/src/mock_vws/_services_validators/width_validators.py @@ -15,8 +15,7 @@ @beartype def validate_width(*, request_body: bytes) -> None: - """ - Validate the width argument given to a VWS endpoint. + """Validate the width argument given to a VWS endpoint. Args: request_body: The body of the request. diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 153c8f580..a06422f8e 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -3,6 +3,7 @@ """ import uuid +from collections.abc import Iterable from dataclasses import dataclass, field from typing import Self, TypedDict @@ -25,7 +26,7 @@ class DatabaseDict(TypedDict): client_access_key: str client_secret_key: str state_name: str - targets: list[TargetDict] + targets: Iterable[TargetDict] @beartype @@ -39,8 +40,7 @@ def _random_hex() -> str: @beartype @dataclass(eq=True, frozen=True) class VuforiaDatabase: - """ - Credentials for VWS APIs. + """Credentials for VWS APIs. Args: database_name: The name of a VWS target manager database name. Defaults diff --git a/src/mock_vws/image_matchers.py b/src/mock_vws/image_matchers.py index 5768ad8b7..eba39996a 100644 --- a/src/mock_vws/image_matchers.py +++ b/src/mock_vws/image_matchers.py @@ -1,4 +1,6 @@ -"""Matchers for query and duplicate requests.""" +""" +Matchers for query and duplicate requests. +""" import io from typing import Protocol, runtime_checkable @@ -14,15 +16,16 @@ @runtime_checkable class ImageMatcher(Protocol): - """Protocol for a matcher for query and duplicate requests.""" + """ + 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. + """Whether one image's content matches another's closely enough. Args: first_image_content: One image's content. @@ -35,15 +38,16 @@ def __call__( @beartype class ExactMatcher: - """A matcher which returns whether two images are exactly equal.""" + """ + 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. + """Whether one image's content matches another's exactly. Args: first_image_content: One image's content. @@ -54,15 +58,16 @@ def __call__( @beartype class StructuralSimilarityMatcher: - """A matcher which returns whether two images are similar using SSIM.""" + """ + 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. + """Whether one image's content matches another's using a SSIM. Args: first_image_content: One image's content. @@ -75,35 +80,38 @@ def __call__( # 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 = first_image.resize(size=target_size) - second_image = second_image.resize(size=target_size) + first_image_resized = first_image.resize(size=target_size) + second_image_resized = second_image.resize(size=target_size) - first_image_np = np.array(first_image, dtype=np.float32) - first_image_tensor = torch.tensor(first_image_np).float() / 255 + first_image_np = np.array(object=first_image_resized, dtype=np.float32) + first_image_tensor = torch.tensor(data=first_image_np).float() / 255 first_image_tensor = first_image_tensor.view( - first_image.size[1], - first_image.size[0], - len(first_image.getbands()), + first_image_resized.size[1], + first_image_resized.size[0], + len(first_image_resized.getbands()), ) - second_image_np = np.array(second_image, dtype=np.float32) - second_image_tensor = torch.tensor(second_image_np).float() / 255 + second_image_np = np.array( + object=second_image_resized, + dtype=np.float32, + ) + second_image_tensor = torch.tensor(data=second_image_np).float() / 255 second_image_tensor = second_image_tensor.view( - second_image.size[1], - second_image.size[0], - len(second_image.getbands()), + 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(0) + ).unsqueeze(dim=0) second_image_tensor_batch_dimension = second_image_tensor.permute( 2, 0, 1, - ).unsqueeze(0) + ).unsqueeze(dim=0) ssim = StructuralSimilarityIndexMeasure(data_range=1.0) ssim_value = ssim( diff --git a/src/mock_vws/states.py b/src/mock_vws/states.py index c3f55b019..7233fd8c9 100644 --- a/src/mock_vws/states.py +++ b/src/mock_vws/states.py @@ -2,12 +2,13 @@ Vuforia database states. """ -from enum import StrEnum, auto +from enum import StrEnum, auto, unique from beartype import beartype @beartype +@unique class States(StrEnum): """ Constants representing various web service states. diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index 67419ec88..b04c3ff52 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -82,13 +82,12 @@ class Target: @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(initial_bytes=self.image_value) image = Image.open(fp=image_file) @@ -105,15 +104,14 @@ def _post_processing_status(self) -> TargetStatuses: @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=float(self.processing_time_seconds), @@ -130,7 +128,9 @@ def status(self) -> str: @property def _post_processing_target_rating(self) -> int: - """The rating of the target after processing.""" + """ + The rating of the target after processing. + """ return self.target_tracking_rater(image_content=self.image_value) @property diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index fb8543c23..9dc08870a 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -2,10 +2,15 @@ A fake implementation of a Vuforia target manager. """ +from typing import TYPE_CHECKING + from beartype import beartype from mock_vws.database import VuforiaDatabase +if TYPE_CHECKING: + from collections.abc import Iterable + @beartype class TargetManager: @@ -17,11 +22,10 @@ def __init__(self) -> None: """ Create a target manager with no databases. """ - self._databases: set[VuforiaDatabase] = set() + self._databases: Iterable[VuforiaDatabase] = set() def remove_database(self, database: VuforiaDatabase) -> None: - """ - Remove a cloud database. + """Remove a cloud database. Args: database: The database to add. @@ -29,11 +33,10 @@ def remove_database(self, database: VuforiaDatabase) -> None: Raises: KeyError: The database is not in the target manager. """ - self._databases.remove(database) + self._databases = {db for db in self._databases if db != database} def add_database(self, database: VuforiaDatabase) -> None: - """ - Add a cloud database. + """Add a cloud database. Args: database: The database to add. @@ -78,11 +81,11 @@ def add_database(self, database: VuforiaDatabase) -> None: message = message_fmt.format(key_name=key_name, value=new) raise ValueError(message) - self._databases.add(database) + self._databases = {*self._databases, database} @property def databases(self) -> set[VuforiaDatabase]: """ All cloud databases. """ - return self._databases + return set(self._databases) diff --git a/src/mock_vws/target_raters.py b/src/mock_vws/target_raters.py index dc7542f4e..d52c48122 100644 --- a/src/mock_vws/target_raters.py +++ b/src/mock_vws/target_raters.py @@ -1,4 +1,6 @@ -"""Raters for target quality.""" +""" +Raters for target quality. +""" import functools import io @@ -7,17 +9,16 @@ from typing import Protocol, runtime_checkable import numpy as np -import piq # type: ignore[import-untyped] 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. + """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 @@ -28,16 +29,16 @@ def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int: """ image_file = io.BytesIO(initial_bytes=image_content) image = Image.open(fp=image_file) - image_np = np.array(image, dtype=np.float32) - image_tensor = torch.tensor(image_np).float() / 255 + image_np = np.array(object=image, dtype=np.float32) + image_tensor = torch.tensor(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(0) + image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(dim=0) try: - brisque_score = piq.brisque(x=image_tensor, data_range=255) + brisque_score = brisque(x=image_tensor, data_range=255) except (AssertionError, IndexError): return 0 return math.ceil(int(brisque_score.item()) / 20) @@ -45,11 +46,12 @@ def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int: @runtime_checkable class TargetTrackingRater(Protocol): - """Protocol for a rater of target quality.""" + """ + Protocol for a rater of target quality. + """ def __call__(self, image_content: bytes) -> int: - """ - The target tracking rating. + """The target tracking rating. Args: image_content: A target's image's content. @@ -61,22 +63,25 @@ def __call__(self, image_content: bytes) -> int: @beartype class RandomTargetTrackingRater: - """A rater which returns a random number.""" + """ + A rater which returns a random number. + """ def __call__(self, image_content: bytes) -> int: - """ - A random target tracking rating. + """A random target tracking rating. Args: image_content: A target's image's content. """ - assert image_content + del image_content return secrets.randbelow(exclusive_upper_bound=6) @beartype class HardcodedTargetTrackingRater: - """A rater which returns a hardcoded number.""" + """ + A rater which returns a hardcoded number. + """ def __init__(self, rating: int) -> None: """ @@ -86,23 +91,23 @@ def __init__(self, rating: int) -> None: self._rating = rating def __call__(self, image_content: bytes) -> int: - """ - A random target tracking rating. + """A random target tracking rating. Args: image_content: A target's image's content. """ - assert image_content + del image_content return self._rating @beartype class BrisqueTargetTrackingRater: - """A rater which returns a rating based on a BRISQUE score.""" + """ + 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. + """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 diff --git a/tests/__init__.py b/tests/__init__.py index 3502d86d5..c7e38a862 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,3 @@ -"""Tests for ``vws``.""" +""" +Tests for ``vws``. +""" diff --git a/tests/conftest.py b/tests/conftest.py index 04f72f1c5..4c05f7cc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,8 +77,7 @@ 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. """ @@ -109,7 +108,7 @@ def endpoint(request: pytest.FixtureRequest) -> Endpoint: """ 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 @@ -135,9 +134,9 @@ def endpoint(request: pytest.FixtureRequest) -> Endpoint: ], ) def not_base64_encoded_processable(request: pytest.FixtureRequest) -> str: - """ - Return a string which is not decodable as base64 data, but Vuforia will + """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 diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index 9ee5749c8..a7e163da7 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -13,7 +13,9 @@ class _VuforiaDatabaseSettings(BaseSettings): - """Settings for a Vuforia database.""" + """ + Settings for a Vuforia database. + """ target_manager_database_name: str server_access_key: str @@ -29,7 +31,9 @@ class _VuforiaDatabaseSettings(BaseSettings): class _InactiveVuforiaDatabaseSettings(_VuforiaDatabaseSettings): - """Settings for an inactive Vuforia database.""" + """ + Settings for an inactive Vuforia database. + """ model_config = SettingsConfigDict( env_prefix="INACTIVE_VUFORIA_", diff --git a/tests/mock_vws/fixtures/prepared_requests.py b/tests/mock_vws/fixtures/prepared_requests.py index 019b77c88..b9c8e4f50 100644 --- a/tests/mock_vws/fixtures/prepared_requests.py +++ b/tests/mock_vws/fixtures/prepared_requests.py @@ -7,10 +7,8 @@ import json from http import HTTPMethod, HTTPStatus from typing import Any -from urllib.parse import urljoin import pytest -import requests from beartype import beartype from urllib3.filepost import encode_multipart_formdata from vws import VWS @@ -27,13 +25,13 @@ @RETRY_ON_TOO_MANY_REQUESTS def _wait_for_target_processed(vws_client: VWS, target_id: str) -> None: - """ - Wait for a target to be processed. + """Wait for a target to be processed. - We retry here because pytest-retry does not retry on exceptions raised in - fixtures. + We retry here because pytest-retry does not retry on exceptions + raised in fixtures. - See https://github.com/str0zzapreti/pytest-retry/issues/33. + See + https://github.com/str0zzapreti/pytest-retry/issues/33. """ vws_client.wait_for_target_processed(target_id=target_id) @@ -47,10 +45,12 @@ def add_target( """ Return details of the endpoint for adding a target. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(s=image_data).decode("ascii") + 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] = { + data = { "name": "example_name", "width": 1, "image": image_data_encoded, @@ -76,22 +76,18 @@ def add_target( headers = { "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, ) @@ -128,20 +124,17 @@ def delete_target( headers = { "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, ) @@ -173,22 +166,18 @@ def database_summary(vuforia_database: VuforiaDatabase) -> Endpoint: headers = { "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, ) @@ -226,22 +215,18 @@ def get_duplicates( headers = { "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, ) @@ -278,22 +263,18 @@ def get_target( headers = { "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, ) @@ -325,22 +306,18 @@ def target_list(vuforia_database: VuforiaDatabase) -> Endpoint: headers = { "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, ) @@ -377,22 +354,18 @@ def target_summary( headers = { "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, ) @@ -431,23 +404,19 @@ def update_target( headers = { "Authorization": authorization_string, - "Date": date, + "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, ) @@ -462,7 +431,7 @@ def 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")} @@ -485,23 +454,19 @@ def query( headers = { "Authorization": authorization_string, + "Content-Length": str(object=len(content)), "Date": date, "Content-Type": content_type_header, } - request = requests.Request( - method=method, - url=urljoin(base=VWQ_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=VWQ_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 7f1df5f25..2c67081f7 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -14,7 +14,7 @@ from requests_mock_flask import add_flask_app_to_mock from vws import VWS from vws.exceptions.vws_exceptions import ( - TargetStatusNotSuccess, + TargetStatusNotSuccessError, ) from mock_vws import MockVWS @@ -31,8 +31,7 @@ @RETRY_ON_TOO_MANY_REQUESTS def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: - """ - Delete all targets. + """Delete all targets. Args: database_keys: The credentials to the Vuforia target database to delete @@ -54,7 +53,7 @@ def _delete_all_targets(*, database_keys: VuforiaDatabase) -> None: ) # Even deleted targets can be matched by a query for a few seconds so # we change the target to inactive before deleting it. - with contextlib.suppress(TargetStatusNotSuccess): + with contextlib.suppress(TargetStatusNotSuccessError): vws_client.update_target(target_id=target, active_flag=False) vws_client.wait_for_target_processed(target_id=target) vws_client.delete_target(target_id=target) @@ -66,8 +65,10 @@ def _enable_use_real_vuforia( working_database: VuforiaDatabase, inactive_database: VuforiaDatabase, monkeypatch: pytest.MonkeyPatch, -) -> Generator[None, None, None]: - """Test against the real Vuforia.""" +) -> Generator[None]: + """ + Test against the real Vuforia. + """ assert monkeypatch assert inactive_database _delete_all_targets(database_keys=working_database) @@ -80,8 +81,10 @@ def _enable_use_mock_vuforia( working_database: VuforiaDatabase, inactive_database: VuforiaDatabase, monkeypatch: pytest.MonkeyPatch, -) -> Generator[None, None, None]: - """Test against the in-memory mock Vuforia.""" +) -> Generator[None]: + """ + Test against the in-memory mock Vuforia. + """ assert monkeypatch working_database = VuforiaDatabase( database_name=working_database.database_name, @@ -112,8 +115,10 @@ def _enable_use_docker_in_memory( working_database: VuforiaDatabase, inactive_database: VuforiaDatabase, monkeypatch: pytest.MonkeyPatch, -) -> Generator[None, None, None]: - """Test against mock Vuforia created to be run in a container.""" +) -> Generator[None]: + """ + Test against mock Vuforia created to be run in a container. + """ # We set ``wsgi.input_terminated`` to ``True`` so that when going through # ``requests`` in our tests, the Flask applications # have the given ``Content-Length`` headers and the given data in @@ -214,7 +219,9 @@ def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Function], ) -> None: - """Skip Docker tests if requested.""" + """ + Skip Docker tests if requested. + """ skip_docker_build_tests_option = "--skip-docker_build_tests" skip_docker_build_tests_marker = pytest.mark.skip( reason=( @@ -225,23 +232,23 @@ def pytest_collection_modifyitems( if config.getoption(name=skip_docker_build_tests_option): for item in items: if "requires_docker_build" in item.keywords: - item.add_marker(skip_docker_build_tests_marker) + item.add_marker(marker=skip_docker_build_tests_marker) @beartype @pytest.fixture( + name="verify_mock_vuforia", params=list(VuforiaBackend), ids=[backend.value for backend in list(VuforiaBackend)], ) -def verify_mock_vuforia( +def fixture_verify_mock_vuforia( request: pytest.FixtureRequest, vuforia_database: VuforiaDatabase, inactive_database: VuforiaDatabase, monkeypatch: pytest.MonkeyPatch, -) -> Generator[None, None, None]: - """ - Test functions which use this fixture are run multiple times. Once with the - real Vuforia, and once with each mock. +) -> 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. @@ -283,10 +290,9 @@ def mock_only_vuforia( vuforia_database: VuforiaDatabase, inactive_database: VuforiaDatabase, monkeypatch: pytest.MonkeyPatch, -) -> Generator[None, None, None]: - """ - Test functions which use this fixture are run multiple times. Once with the - each mock. +) -> 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. diff --git a/tests/mock_vws/test_add_target.py b/tests/mock_vws/test_add_target.py index fdb627eac..778392a08 100644 --- a/tests/mock_vws/test_add_target.py +++ b/tests/mock_vws/test_add_target.py @@ -8,35 +8,32 @@ from http import HTTPMethod, HTTPStatus from string import hexdigits from typing import Any, Final -from urllib.parse import urljoin import pytest -import requests from beartype import beartype from dirty_equals import IsInstance -from requests.structures import CaseInsensitiveDict from vws import VWS -from vws.exceptions.custom_exceptions import OopsAnErrorOccurredPossiblyBadName -from vws.exceptions.response import Response +from vws.exceptions.custom_exceptions import ( + OopsAnErrorOccurredPossiblyBadNameError, +) from vws.exceptions.vws_exceptions import ( - BadImage, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectInactive, - TargetNameExist, + AuthenticationFailureError, + BadImageError, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectInactiveError, + TargetNameExistError, ) -from vws_auth_tools import authorization_header, rfc_1123_date +from vws.types 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, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors _MAX_METADATA_BYTES: Final[int] = 1024 * 1024 - 1 @@ -44,58 +41,33 @@ @beartype def _add_target_to_vws( *, - vuforia_database: VuforiaDatabase, + vws_client: VWS, data: dict[str, Any], content_type: str = "application/json", -) -> requests.Response: - """ - Return a response from a request to the endpoint to add a target. +) -> Response: + """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 = json.dumps(obj=data).encode(encoding="utf-8") - - authorization_string = authorization_header( - access_key=vuforia_database.server_access_key, - secret_key=vuforia_database.server_secret_key, + return vws_client.make_request( method=HTTPMethod.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=HTTPMethod.POST, - url=urljoin(base="https://vws.vuforia.com/", url=request_path), - headers=headers, data=content, - timeout=30, + request_path="/targets", + expected_result_code=ResultCodes.TARGET_CREATED.value, + content_type=content_type, ) - handle_server_errors(response=response) - return response - @beartype def _assert_oops_response(response: Response) -> None: - """ - Assert that the response is in the format of Vuforia's "Oops, an error + """Assert that the response is in the format of Vuforia's "Oops, an error occurred" HTML response. Raises: @@ -105,25 +77,22 @@ def _assert_oops_response(response: Response) -> None: assert "Oops, an error occurred" in response.text assert "This exception has been logged with id" in response.text - expected_headers = CaseInsensitiveDict( - data={ - "Connection": "keep-alive", - "Content-Type": "text/html; charset=UTF-8", - "Date": response.headers["Date"], - "server": "envoy", - "Content-Length": "1190", - "x-envoy-upstream-service-time": IsInstance(expected_type=str), - "strict-transport-security": "max-age=31536000", - "x-aws-region": IsInstance(expected_type=str), - "x-content-type-options": "nosniff", - }, - ) + expected_headers = { + "Connection": "keep-alive", + "Content-Type": "text/html; charset=UTF-8", + "Date": response.headers["Date"], + "server": "envoy", + "Content-Length": "1190", + "x-envoy-upstream-service-time": IsInstance(expected_type=str), + "strict-transport-security": "max-age=31536000", + "x-aws-region": IsInstance(expected_type=str), + "x-content-type-options": "nosniff", + } assert response.headers == expected_headers -def assert_success(response: requests.Response) -> None: - """ - Assert that the given response is a success response for adding a +def assert_success(response: Response) -> None: + """Assert that the given response is a success response for adding a target. Raises: @@ -136,11 +105,13 @@ def assert_success(response: requests.Response) -> None: 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"] + 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") @@ -151,8 +122,8 @@ class TestContentTypes: @staticmethod @pytest.mark.parametrize( - "content_type", - [ + argnames="content_type", + argvalues=[ # This is the documented required content type: "application/json", # Other content types also work. @@ -164,15 +135,17 @@ class TestContentTypes: ], ) def test_content_types( - 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(s=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", @@ -181,7 +154,7 @@ def test_content_types( } response = _add_target_to_vws( - vuforia_database=vuforia_database, + vws_client=vws_client, data=data, content_type=content_type, ) @@ -190,15 +163,17 @@ def test_content_types( @staticmethod def test_empty_content_type( - vuforia_database: VuforiaDatabase, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ 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(s=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", @@ -206,14 +181,17 @@ def test_empty_content_type( "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, ) @@ -226,17 +204,22 @@ class TestMissingData: """ @staticmethod - @pytest.mark.parametrize("data_to_remove", ["name", "width", "image"]) + @pytest.mark.parametrize( + argnames="data_to_remove", + argvalues=["name", "width", "image"], + ) def test_missing_data( - 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(s=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", @@ -245,13 +228,11 @@ def test_missing_data( } 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, ) @@ -265,20 +246,22 @@ class TestWidth: @staticmethod @pytest.mark.parametrize( - "width", - [-1, "10", None, 0], + argnames="width", + argvalues=[-1, "10", None, 0], ids=["Negative", "Wrong Type", "None", "Zero"], ) def test_width_invalid( - vuforia_database: VuforiaDatabase, + vws_client: VWS, image_file_failed_state: io.BytesIO, 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(s=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", @@ -286,13 +269,11 @@ def test_width_invalid( "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, ) @@ -325,8 +306,8 @@ class TestTargetName: @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 @@ -385,29 +366,30 @@ def test_name_invalid( 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.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + data = { + "name": name, + "width": 1, + "image": image_data_encoded, + "application_metadata": None, + "active_flag": True, + } + if status_code == HTTPStatus.INTERNAL_SERVER_ERROR: with pytest.raises( - expected_exception=OopsAnErrorOccurredPossiblyBadName, + expected_exception=OopsAnErrorOccurredPossiblyBadNameError, ) as oops_exc: - vws_client.add_target( - name=name, # type: ignore[arg-type] - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) + _add_target_to_vws(vws_client=vws_client, data=data) assert oops_exc.value.response.status_code == status_code _assert_oops_response(response=oops_exc.value.response) return - with pytest.raises(expected_exception=Fail) as exc: - vws_client.add_target( - name=name, # type: ignore[arg-type] - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) + with pytest.raises(expected_exception=FailError) as exc: + _add_target_to_vws(vws_client=vws_client, data=data) + assert_vws_failure( response=exc.value.response, status_code=status_code, @@ -430,7 +412,7 @@ def test_existing_target_name( active_flag=True, ) - with pytest.raises(expected_exception=TargetNameExist) as exc: + with pytest.raises(expected_exception=TargetNameExistError) as exc: vws_client.add_target( name="example_name", width=1, @@ -474,8 +456,7 @@ def test_deleted_existing_target_name( @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. @@ -507,7 +488,7 @@ def test_bad_image_format_or_color_space( a JPEG or PNG file is given, or if the given image is not in the greyscale or RGB color space. """ - with pytest.raises(expected_exception=BadImage) as exc: + with pytest.raises(expected_exception=BadImageError) as exc: vws_client.add_target( name="example_name", width=1, @@ -541,8 +522,8 @@ def test_corrupted( @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 @@ -590,7 +571,7 @@ def test_image_file_size_too_large(vws_client: VWS) -> None: assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - with pytest.raises(expected_exception=ImageTooLarge) as exc: + with pytest.raises(expected_exception=ImageTooLargeError) as exc: vws_client.add_target( name="example_name_2", width=1, @@ -607,14 +588,14 @@ def test_image_file_size_too_large(vws_client: VWS) -> None: @staticmethod def test_not_base64_encoded_processable( - 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", @@ -622,20 +603,18 @@ def test_not_base64_encoded_processable( "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( - vuforia_database: VuforiaDatabase, + vws_client: VWS, not_base64_encoded_not_processable: str, ) -> None: """ @@ -649,13 +628,11 @@ def test_not_base64_encoded_not_processable( "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, ) @@ -663,10 +640,10 @@ def test_not_base64_encoded_not_processable( @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. """ - with pytest.raises(expected_exception=BadImage) as exc: + with pytest.raises(expected_exception=BadImageError) as exc: vws_client.add_target( name="example_name", width=1, @@ -682,10 +659,13 @@ def test_not_image(vws_client: VWS) -> None: ) @staticmethod - @pytest.mark.parametrize("invalid_type_image", [1, None]) + @pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], + ) def test_invalid_type( invalid_type_image: int | None, - vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: """ If the given image is not a string, a `Fail` result is returned. @@ -696,13 +676,11 @@ def test_invalid_type( "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, ) @@ -715,17 +693,22 @@ class TestActiveFlag: """ @staticmethod - @pytest.mark.parametrize("active_flag", [True, False, None]) + @pytest.mark.parametrize( + argnames="active_flag", + argvalues=[True, False, None], + ) def test_valid( 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(s=image_data).decode("ascii") + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) content_type = "application/json" data = { @@ -736,7 +719,7 @@ def test_valid( } response = _add_target_to_vws( - vuforia_database=vuforia_database, + vws_client=vws_client, data=data, content_type=content_type, ) @@ -746,14 +729,16 @@ def test_valid( @staticmethod def test_invalid( 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. """ active_flag = "string" - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(s=image_data).decode("ascii") + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) content_type = "application/json" data = { @@ -763,29 +748,31 @@ def test_invalid( "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( - 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(s=image_data).decode("ascii") + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { "name": "my_example_name", @@ -793,26 +780,24 @@ def test_not_set( "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( - 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(s=image_data).decode("ascii") + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) data = { "name": "my_example_name", @@ -821,12 +806,10 @@ def test_set_to_none( "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 @@ -839,14 +822,16 @@ class TestUnexpectedData: @staticmethod def test_invalid_extra_data( - vuforia_database: VuforiaDatabase, + vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: """ A `BAD_REQUEST` response is returned when unexpected data is given. """ - image_data = image_file_failed_state.read() - image_data_encoded = base64.b64encode(s=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", @@ -855,13 +840,11 @@ def test_invalid_extra_data( "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, ) @@ -875,8 +858,8 @@ class TestApplicationMetadata: @staticmethod @pytest.mark.parametrize( - "metadata", - [ + argnames="metadata", + argvalues=[ b"a", b"a" * _MAX_METADATA_BYTES, ], @@ -890,7 +873,9 @@ def test_base64_encoded( """ A base64 encoded string is valid application metadata. """ - metadata_encoded = base64.b64encode(s=metadata).decode("ascii") + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) vws_client.add_target( name="example", @@ -902,14 +887,16 @@ def test_base64_encoded( @staticmethod def test_null( - 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(s=image_data).decode("ascii") + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) request_data = { "name": "example_name", @@ -919,7 +906,7 @@ def test_null( } response = _add_target_to_vws( - vuforia_database=vuforia_database, + vws_client=vws_client, data=request_data, ) @@ -927,15 +914,17 @@ def test_null( @staticmethod def test_invalid_type( - 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(s=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", @@ -944,13 +933,11 @@ def test_invalid_type( "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, ) @@ -983,7 +970,7 @@ def test_not_base64_encoded_not_processable( Some strings which are not valid base64 encoded strings are not allowed as application metadata. """ - with pytest.raises(expected_exception=Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.add_target( name="example", width=1, @@ -1008,9 +995,11 @@ def test_metadata_too_large( for application metadata. """ metadata = b"a" * (_MAX_METADATA_BYTES + 1) - metadata_encoded = base64.b64encode(s=metadata).decode("ascii") + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) - with pytest.raises(expected_exception=MetadataTooLarge) as exc: + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: vws_client.add_target( name="example", width=1, @@ -1040,7 +1029,7 @@ def test_inactive_project( """ If the project is inactive, a FORBIDDEN response is returned. """ - with pytest.raises(expected_exception=ProjectInactive) as exc: + with pytest.raises(expected_exception=ProjectInactiveError) as exc: inactive_vws_client.add_target( name="example", width=1, diff --git a/tests/mock_vws/test_authorization_header.py b/tests/mock_vws/test_authorization_header.py index 52bcc7a1c..3cde8cd05 100644 --- a/tests/mock_vws/test_authorization_header.py +++ b/tests/mock_vws/test_authorization_header.py @@ -9,10 +9,9 @@ from urllib.parse import urlparse import pytest -import requests 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 @@ -39,15 +38,29 @@ def test_missing(endpoint: Endpoint) -> None: is given. """ date = rfc_1123_date() - endpoint.prepared_request.headers.update({"Date": date}) - endpoint.prepared_request.headers.pop("Authorization", None) + 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, + ) + + response = new_endpoint.send() - session = requests.Session() - response = session.send(request=endpoint.prepared_request) handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, @@ -75,8 +88,9 @@ class TestMalformed: @staticmethod def test_one_part_no_space(endpoint: Endpoint) -> None: - """ - A valid authorization string is two "parts" when split on a space. When + """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. """ @@ -86,16 +100,28 @@ def test_one_part_no_space(endpoint: Endpoint) -> None: # string, but really any string which is not two parts when split on a # space will do. authorization_string = "VWS" - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date}, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, @@ -116,24 +142,36 @@ def test_one_part_no_space(endpoint: Endpoint) -> None: @staticmethod def test_one_part_with_space(endpoint: Endpoint) -> None: - """ - A valid authorization string is two "parts" when split on a space. When + """A valid authorization string is two "parts" when split on a space. + + When a string is given which is one "part", a ``BAD_REQUEST`` or ``UNAUTHORIZED`` response is returned. """ authorization_string = "VWS " date = rfc_1123_date() + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } - endpoint.prepared_request.headers.update( - {"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, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, @@ -161,16 +199,28 @@ def test_missing_signature(endpoint: Endpoint) -> None: date = rfc_1123_date() authorization_string = "VWS foobar:" - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date}, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, @@ -209,7 +259,7 @@ def test_bad_access_key_services( server_secret_key=vuforia_database.server_secret_key, ) - with pytest.raises(expected_exception=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 @@ -229,7 +279,7 @@ def test_bad_access_key_query( ) with pytest.raises( - expected_exception=cloud_reco_exceptions.AuthenticationFailure + expected_exception=cloud_reco_exceptions.AuthenticationFailureError ) as exc: cloud_reco_client.query(image=high_quality_image) @@ -267,14 +317,14 @@ def test_bad_secret_key_services( ) -> 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", ) - with pytest.raises(expected_exception=AuthenticationFailure): + with pytest.raises(expected_exception=AuthenticationFailureError): vws_client.get_target_record(target_id=uuid.uuid4().hex) @staticmethod @@ -292,7 +342,7 @@ def test_bad_secret_key_query( ) with pytest.raises( - expected_exception=cloud_reco_exceptions.AuthenticationFailure + expected_exception=cloud_reco_exceptions.AuthenticationFailureError ) as exc: cloud_reco_client.query(image=high_quality_image) diff --git a/tests/mock_vws/test_content_length.py b/tests/mock_vws/test_content_length.py index c7db1f235..12160dc2e 100644 --- a/tests/mock_vws/test_content_length.py +++ b/tests/mock_vws/test_content_length.py @@ -7,8 +7,6 @@ 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 @@ -22,8 +20,7 @@ @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 @@ -36,28 +33,39 @@ def test_not_integer(endpoint: Endpoint) -> None: A ``BAD_REQUEST`` error is given when the given ``Content-Length`` is not an integer. """ - if not endpoint.prepared_request.headers.get("Content-Type"): + if not endpoint.headers.get("Content-Type"): return content_length = "0.4" - endpoint.prepared_request.headers.update( - {"Content-Length": content_length}, + + 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, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + + response = new_endpoint.send() handle_server_errors(response=response) assert response.status_code == HTTPStatus.BAD_REQUEST - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert not response.text - assert response.headers == CaseInsensitiveDict( - data={ - "Content-Length": str(len(response.text)), - "Connection": "Close", - }, - ) + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "Close", + } return assert_valid_date_header(response=response) @@ -72,15 +80,13 @@ def test_not_integer(endpoint: Endpoint) -> None: """, ) assert response.text == expected_response_text - expected_headers = CaseInsensitiveDict( - data={ - "Content-Length": str(len(response.text)), - "Content-Type": "text/html", - "Connection": "close", - "server": "awselb/2.0", - "Date": response.headers["Date"], - }, - ) + 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 @@ -89,31 +95,42 @@ 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.prepared_request.headers.get("Content-Type"): - pytest.skip("No Content-Type header for this request") + if not endpoint.headers.get("Content-Type"): + pytest.skip(reason="No Content-Type header for this request") - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc content_length = str( - int(endpoint.prepared_request.headers["Content-Length"]) + 1 + object=int(endpoint.headers["Content-Length"]) + 1 ) - endpoint.prepared_request.headers.update( - {"Content-Length": content_length} + + 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, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + 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 == CaseInsensitiveDict( - data={ - "Content-Length": str(len(response.text)), - "Connection": "keep-alive", - }, - ) + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "keep-alive", + } return handle_server_errors(response=response) @@ -121,15 +138,13 @@ def test_too_large(endpoint: Endpoint) -> None: # pragma: no cover # We have seen both of these response texts. assert response.text in {"stream timeout", ""} expected_headers = { - "Content-Length": str(len(response.text)), + "Content-Length": str(object=len(response.text)), "Connection": "close", "Content-Type": "text/plain", "server": "envoy", "Date": response.headers["Date"], } - assert response.headers == CaseInsensitiveDict( - data=expected_headers, - ) + assert response.headers == expected_headers assert response.status_code == HTTPStatus.REQUEST_TIMEOUT @staticmethod @@ -138,22 +153,34 @@ def test_too_small(endpoint: Endpoint) -> None: An ``UNAUTHORIZED`` response is given if the given content length is too small. """ - if not endpoint.prepared_request.headers.get("Content-Type"): + if not endpoint.headers.get("Content-Type"): return - content_length = str( - int(endpoint.prepared_request.headers["Content-Length"]) - 1 - ) - endpoint.prepared_request.headers.update( - {"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, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, diff --git a/tests/mock_vws/test_database_summary.py b/tests/mock_vws/test_database_summary.py index 91de33bd9..d1ce06f32 100644 --- a/tests/mock_vws/test_database_summary.py +++ b/tests/mock_vws/test_database_summary.py @@ -14,7 +14,7 @@ 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 @@ -37,7 +37,7 @@ def _log_attempt_number(retry_state: RetryCallState) -> None: # 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(0.2), + 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 @@ -54,9 +54,8 @@ def _wait_for_image_numbers( failed_images: int, processing_images: int, ) -> None: - """ - Wait for the number of images in various categories of the database summary - to match the expected given numbers. + """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. @@ -238,14 +237,13 @@ def test_deleted( class TestProcessingImages: - """ - Tests for processing images. + """Tests for processing images. - 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 + 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 @@ -288,8 +286,8 @@ class TestQuotas: @staticmethod def test_quotas(vws_client: VWS) -> None: - """ - Quotas are included in the database summary. + """Quotas are included in the database summary. + These match the quotas given for a free license. """ report = vws_client.get_database_summary_report() @@ -313,12 +311,11 @@ def test_query_request( 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, @@ -375,7 +372,7 @@ def test_bad_target_request( report = vws_client.get_database_summary_report() original_request_usage = report.request_usage - with pytest.raises(expected_exception=Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.add_target( name="example", width=-1, diff --git a/tests/mock_vws/test_date_header.py b/tests/mock_vws/test_date_header.py index 3a27d9110..9c4c2302d 100644 --- a/tests/mock_vws/test_date_header.py +++ b/tests/mock_vws/test_date_header.py @@ -2,13 +2,13 @@ Tests for the `Date` header. """ +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 vws_auth_tools import authorization_header, rfc_1123_date @@ -42,23 +42,36 @@ def test_no_date_header(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", - content=endpoint.prepared_request.body, + method=endpoint.method, + content=endpoint.data, content_type=endpoint.auth_header_content_type, date="", - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string} + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + } + 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, ) - endpoint.prepared_request.headers.pop("Date", None) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + + response = new_endpoint.send() + handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": expected_content_type = "text/plain;charset=iso-8859-1" @@ -83,14 +96,13 @@ def test_no_date_header(endpoint: Endpoint) -> None: @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. """ @staticmethod def test_incorrect_date_format(endpoint: Endpoint) -> None: - """ - A `BAD_REQUEST` response is returned when the date given in the date + """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. @@ -98,31 +110,40 @@ def test_incorrect_date_format(endpoint: Endpoint) -> None: gmt = ZoneInfo(key="GMT") with freeze_time(time_to_freeze=datetime.now(tz=gmt)): now = datetime.now(tz=gmt) - date_incorrect_format = now.strftime("%a %b %d %H:%M:%S") + 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=endpoint.prepared_request.method or "", - content=endpoint.prepared_request.body, + 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, ) - endpoint.prepared_request.headers.update( - { - "Authorization": authorization_string, - "Date": date_incorrect_format, - }, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date_incorrect_format, + } + + 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() - session = requests.Session() - response = session.send(request=endpoint.prepared_request) handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert response.text == "Malformed date header." assert_vwq_failure( @@ -145,22 +166,20 @@ def test_incorrect_date_format(endpoint: Endpoint) -> None: @pytest.mark.usefixtures("verify_mock_vuforia") class TestSkewedTime: """ - Tests for what happens when the `Date` header is given with an - unexpected time. + Tests for what happens when the `Date` header is given with an unexpected + time. """ @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 + """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 = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { "vws.vuforia.com": _VWS_MAX_TIME_SKEW, "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, @@ -175,25 +194,41 @@ def test_date_out_of_range_after(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", - content=endpoint.prepared_request.body, + 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, ) - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date} + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) # Even with the query endpoint, we get a JSON response. if netloc == "cloudreco.vuforia.com": - assert response.json().keys() == {"transaction_id", "result_code"} - assert response.json()["result_code"] == "RequestTimeTooSkewed" + 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, @@ -213,16 +248,14 @@ def test_date_out_of_range_after(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 + """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. + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { "vws.vuforia.com": _VWS_MAX_TIME_SKEW, "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, @@ -237,25 +270,41 @@ def test_date_out_of_range_before(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", - content=endpoint.prepared_request.body, + 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, ) - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date}, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) # Even with the query endpoint, we get a JSON response. if netloc == "cloudreco.vuforia.com": - assert response.json().keys() == {"transaction_id", "result_code"} - assert response.json()["result_code"] == "RequestTimeTooSkewed" + 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, @@ -275,15 +324,13 @@ def test_date_out_of_range_before(endpoint: Endpoint) -> None: @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. + """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. + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { "vws.vuforia.com": _VWS_MAX_TIME_SKEW, "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, @@ -298,23 +345,36 @@ def test_date_in_range_after(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", - content=endpoint.prepared_request.body, + 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, ) - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date}, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_query_success(response=response) return @@ -327,15 +387,13 @@ def test_date_in_range_after(endpoint: Endpoint) -> None: @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. + """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. + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. """ - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc skew = { "vws.vuforia.com": _VWS_MAX_TIME_SKEW, "cloudreco.vuforia.com": _VWQ_MAX_TIME_SKEW, @@ -350,23 +408,36 @@ def test_date_in_range_before(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", - content=endpoint.prepared_request.body, + 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, ) - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date}, + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, ) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_query_success(response=response) return diff --git a/tests/mock_vws/test_delete_target.py b/tests/mock_vws/test_delete_target.py index de06137ad..755d9b24c 100644 --- a/tests/mock_vws/test_delete_target.py +++ b/tests/mock_vws/test_delete_target.py @@ -7,9 +7,9 @@ import pytest from vws import VWS from vws.exceptions.vws_exceptions import ( - ProjectInactive, - TargetStatusProcessing, - UnknownTarget, + ProjectInactiveError, + TargetStatusProcessingError, + UnknownTargetError, ) from mock_vws._constants import ResultCodes @@ -24,8 +24,7 @@ class TestDelete: @staticmethod def test_no_wait(target_id: str, vws_client: VWS) -> None: - """ - When attempting to delete a target immediately after creating it, a + """When attempting to delete a target immediately after creating it, a `FORBIDDEN` response is returned. This is because the target goes into a processing state. @@ -33,7 +32,9 @@ def test_no_wait(target_id: str, vws_client: VWS) -> None: There is a race condition here - if the target goes into a success or fail state before the deletion attempt. """ - with pytest.raises(expected_exception=TargetStatusProcessing) as exc: + with pytest.raises( + expected_exception=TargetStatusProcessingError + ) as exc: vws_client.delete_target(target_id=target_id) assert_vws_failure( @@ -50,7 +51,7 @@ def test_processed(target_id: str, vws_client: VWS) -> None: vws_client.wait_for_target_processed(target_id=target_id) vws_client.delete_target(target_id=target_id) - with pytest.raises(expected_exception=UnknownTarget): + with pytest.raises(expected_exception=UnknownTargetError): vws_client.get_target_record(target_id=target_id) @@ -66,7 +67,7 @@ def test_inactive_project(inactive_vws_client: VWS) -> None: If the project is inactive, a FORBIDDEN response is returned. """ target_id = "abc12345a" - with pytest.raises(expected_exception=ProjectInactive) as exc: + 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 3ad5a0ef5..68f3bdbfa 100644 --- a/tests/mock_vws/test_docker.py +++ b/tests/mock_vws/test_docker.py @@ -4,7 +4,7 @@ import io import uuid -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from http import HTTPStatus from typing import TYPE_CHECKING @@ -54,8 +54,7 @@ def wait_for_health_check(container: Container) -> None: @beartype @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. @@ -76,13 +75,13 @@ def fixture_custom_bridge_network() -> Iterator[Network]: yield network finally: network.reload() - images_to_remove: set[Image] = set() + 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.add(container.image) + images_to_remove = {*images_to_remove, container.image} # This does leave behind untagged images. for image in images_to_remove: @@ -112,8 +111,8 @@ def test_build_and_run( try: target_manager_image, _ = client.images.build( - path=str(repository_root), - dockerfile=str(dockerfile), + path=str(object=repository_root), + dockerfile=str(object=dockerfile), tag=target_manager_tag, target="target-manager", rm=True, @@ -133,16 +132,16 @@ def test_build_and_run( ) vwq_image, _ = client.images.build( - path=str(repository_root), - dockerfile=str(dockerfile), + path=str(object=repository_root), + dockerfile=str(object=dockerfile), tag=vwq_tag, target="vwq", rm=True, ) vws_image, _ = client.images.build( - path=str(repository_root), - dockerfile=str(dockerfile), + path=str(object=repository_root), + dockerfile=str(object=dockerfile), tag=vws_tag, target="vws", rm=True, diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index 08f00f170..551dc9907 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -3,6 +3,7 @@ """ import io +import json import uuid from collections.abc import Iterator from http import HTTPStatus @@ -100,7 +101,7 @@ def test_custom( seconds = 5.0 monkeypatch.setenv( name="PROCESSING_TIME_SECONDS", - value=str(seconds), + value=str(object=seconds), ) database = VuforiaDatabase() databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/databases" @@ -188,7 +189,7 @@ def test_give_no_details(high_quality_image: io.BytesIO) -> None: response = requests.post(url=databases_url, json={}, timeout=30) assert response.status_code == HTTPStatus.CREATED - data = response.json() + data = json.loads(s=response.text) assert data["targets"] == [] assert data["state_name"] == "WORKING" @@ -233,7 +234,7 @@ def test_delete_database() -> None: 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 @@ -243,14 +244,18 @@ def test_delete_database() -> None: class TestQueryImageMatchers: - """Tests for query image matchers.""" + """ + Tests for query image matchers. + """ @staticmethod def test_exact_match( high_quality_image: io.BytesIO, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The exact matcher matches only exactly the same images.""" + """ + The exact matcher matches only exactly the same images. + """ monkeypatch.setenv(name="QUERY_IMAGE_MATCHER", value="exact") database = VuforiaDatabase() @@ -266,7 +271,7 @@ def test_exact_match( pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/databases" requests.post(url=databases_url, json=database.to_dict(), timeout=30) @@ -294,7 +299,9 @@ def test_structural_similarity_matcher( different_high_quality_image: io.BytesIO, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The structural similarity matcher matches similar images.""" + """ + The structural similarity matcher matches similar images. + """ monkeypatch.setenv( name="QUERY_IMAGE_MATCHER", value="structural_similarity", @@ -311,7 +318,7 @@ def test_structural_similarity_matcher( pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/databases" requests.post(url=databases_url, json=database.to_dict(), timeout=30) @@ -341,14 +348,18 @@ def test_structural_similarity_matcher( class TestDuplicatesImageMatchers: - """Tests for duplicates image matchers.""" + """ + 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.""" + """ + The exact matcher matches only exactly the same images. + """ monkeypatch.setenv(name="DUPLICATES_IMAGE_MATCHER", value="exact") database = VuforiaDatabase() vws_client = VWS( @@ -358,7 +369,7 @@ def test_exact_match( pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/databases" requests.post(url=databases_url, json=database.to_dict(), timeout=30) @@ -397,7 +408,9 @@ def test_structural_similarity_matcher( high_quality_image: io.BytesIO, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The structural similarity matcher matches similar images.""" + """ + The structural similarity matcher matches similar images. + """ monkeypatch.setenv( name="DUPLICATES_IMAGE_MATCHER", value="structural_similarity", @@ -410,7 +423,7 @@ def test_structural_similarity_matcher( pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/databases" requests.post(url=databases_url, json=database.to_dict(), timeout=30) @@ -433,3 +446,204 @@ def test_structural_similarity_matcher( 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( + corrupted_image_file: io.BytesIO, + high_quality_image: io.BytesIO, + ) -> None: + """ + By default, the BRISQUE target rater is used. + """ + database = VuforiaDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/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, + ) + + corrupted_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=corrupted_image_file, + 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 ( + corrupted_image_target_id, + high_quality_image_target_id, + ): + vws_client.wait_for_target_processed(target_id=target_id) + + corrupted_image_rating = vws_client.get_target_record( + target_id=corrupted_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 + + # In the real Vuforia, this image may rate as -2. + assert corrupted_image_rating <= 0 + assert high_quality_image_rating > 1 + + @staticmethod + def test_brisque( + monkeypatch: pytest.MonkeyPatch, + corrupted_image_file: 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 = VuforiaDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/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, + ) + + corrupted_image_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=corrupted_image_file, + 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 ( + corrupted_image_target_id, + high_quality_image_target_id, + ): + vws_client.wait_for_target_processed(target_id=target_id) + + corrupted_image_rating = vws_client.get_target_record( + target_id=corrupted_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 + + # In the real Vuforia, this image may rate as -2. + assert corrupted_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 = VuforiaDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/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 = VuforiaDatabase() + databases_url = _EXAMPLE_URL_FOR_TARGET_MANAGER + "/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 diff --git a/tests/mock_vws/test_get_duplicates.py b/tests/mock_vws/test_get_duplicates.py index 2661d903a..c52b64730 100644 --- a/tests/mock_vws/test_get_duplicates.py +++ b/tests/mock_vws/test_get_duplicates.py @@ -9,7 +9,7 @@ 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 @@ -74,11 +74,11 @@ def test_duplicates_not_same( Target IDs of similar targets are returned. """ image_data = high_quality_image - similar_image_data = copy.copy(image_data) + 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(similar_image_buffer, format="JPEG") + 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( @@ -156,8 +156,7 @@ def test_active_flag( 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` can have duplicates. Targets with `active_flag` set to `False` are not found as duplicates. https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check @@ -211,8 +210,8 @@ def test_processing( 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( @@ -264,7 +263,7 @@ def test_inactive_project(inactive_vws_client: VWS) -> None: """ If the project is inactive, a FORBIDDEN response is returned. """ - with pytest.raises(expected_exception=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 9b9dcf04f..99fd6a1d4 100644 --- a/tests/mock_vws/test_get_target.py +++ b/tests/mock_vws/test_get_target.py @@ -1,5 +1,4 @@ -""" -Tests for getting a target record. +"""Tests for getting a target record. https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record """ @@ -9,7 +8,7 @@ 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 @@ -84,13 +83,13 @@ def test_success_status( 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", @@ -182,5 +181,5 @@ 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=UnknownTarget): + 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 364a66f7f..7857868e8 100644 --- a/tests/mock_vws/test_invalid_given_id.py +++ b/tests/mock_vws/test_invalid_given_id.py @@ -1,12 +1,11 @@ """ -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 @@ -18,8 +17,8 @@ @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 @@ -32,14 +31,14 @@ def test_not_real_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): + 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( diff --git a/tests/mock_vws/test_invalid_json.py b/tests/mock_vws/test_invalid_json.py index d5a48dab3..afc3b75ea 100644 --- a/tests/mock_vws/test_invalid_json.py +++ b/tests/mock_vws/test_invalid_json.py @@ -2,13 +2,13 @@ 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 vws_auth_tools import authorization_header, rfc_1123_date @@ -44,21 +44,34 @@ def test_invalid_json(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", + 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, ) - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date} + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), + } + + 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, ) - endpoint.prepared_request.body = content - endpoint.prepared_request.prepare_content_length(body=content) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) takes_json_data = ( @@ -75,8 +88,7 @@ def test_invalid_json(endpoint: Endpoint) -> None: ) return - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": assert_vwq_failure( response=response, @@ -113,21 +125,34 @@ def test_invalid_json_with_skewed_time(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", + 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, ) - endpoint.prepared_request.headers.update( - {"Authorization": authorization_string, "Date": date}, + 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, ) - endpoint.prepared_request.body = content - endpoint.prepared_request.prepare_content_length(body=content) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) takes_json_data = ( @@ -144,14 +169,15 @@ def test_invalid_json_with_skewed_time(endpoint: Endpoint) -> None: ) return - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": - assert response.json().keys() == { + 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 response_json["result_code"] == "RequestTimeTooSkewed" assert_valid_transaction_id(response=response) assert_vwq_failure( response=response, diff --git a/tests/mock_vws/test_query.py b/tests/mock_vws/test_query.py index eab124550..82bb18c88 100644 --- a/tests/mock_vws/test_query.py +++ b/tests/mock_vws/test_query.py @@ -1,5 +1,4 @@ -""" -Tests for the mock of the query endpoint. +"""Tests for the mock of the query endpoint. https://developer.vuforia.com/library/web-api/vuforia-query-web-api. """ @@ -10,12 +9,11 @@ import datetime import io import json -import sys import textwrap import time import uuid from http import HTTPMethod, HTTPStatus -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin from zoneinfo import ZoneInfo @@ -30,12 +28,13 @@ from urllib3.filepost import encode_multipart_formdata from vws import VWS, CloudRecoService from vws.exceptions.cloud_reco_exceptions import ( - BadImage, - InactiveProject, - MaxNumResultsOutOfRange, + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, ) -from vws.exceptions.custom_exceptions import RequestEntityTooLarge +from vws.exceptions.custom_exceptions import RequestEntityTooLargeError from vws.reports import TargetStatuses +from vws.types import Response from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws.database import VuforiaDatabase @@ -47,6 +46,9 @@ ) from tests.mock_vws.utils.too_many_requests import handle_server_errors +if TYPE_CHECKING: + from collections.abc import Iterable + VWQ_HOST = "https://cloudreco.vuforia.com" _JETTY_CONTENT_TYPE_ERROR = textwrap.dedent( @@ -87,9 +89,8 @@ def _query( *, vuforia_database: VuforiaDatabase, body: dict[str, Any], -) -> requests.Response: - """ - Make a request to the endpoint to make an image recognition query. +) -> Response: + """Make a request to the endpoint to make an image recognition query. Args: vuforia_database: The credentials to use to connect to @@ -124,7 +125,7 @@ def _query( } vwq_host = "https://cloudreco.vuforia.com" - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=vwq_host, url=request_path), headers=headers, @@ -132,8 +133,16 @@ def _query( timeout=30, ) - handle_server_errors(response=response) - 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(), + ) + handle_server_errors(response=vws_response) + return vws_response @pytest.mark.usefixtures("verify_mock_vuforia") @@ -193,6 +202,7 @@ class TestContentType: ], ) def test_incorrect_no_boundary( + *, high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, content_type: str, @@ -229,7 +239,7 @@ def test_incorrect_no_boundary( "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, @@ -237,11 +247,19 @@ def test_incorrect_no_boundary( timeout=30, ) - handle_server_errors(response=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(), + ) + handle_server_errors(response=vws_response) - assert response.text == resp_text + assert requests_response.text == resp_text assert_vwq_failure( - response=response, + response=vws_response, status_code=resp_status_code, content_type=resp_content_type, cache_control=resp_cache_control, @@ -289,7 +307,7 @@ def test_incorrect_with_boundary( "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, @@ -297,10 +315,18 @@ def test_incorrect_with_boundary( timeout=30, ) - handle_server_errors(response=response) - assert not 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(), + ) + 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, @@ -310,8 +336,8 @@ def test_incorrect_with_boundary( @staticmethod @pytest.mark.parametrize( - "content_type", - [ + argnames="content_type", + argvalues=[ "multipart/form-data", "multipart/form-data; extra", "multipart/form-data; extra=1", @@ -351,7 +377,7 @@ def test_no_boundary( "Content-Type": content_type, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, @@ -359,15 +385,23 @@ def test_no_boundary( timeout=30, ) - handle_server_errors(response=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(), + ) + handle_server_errors(response=vws_response) expected_text = ( "java.io.IOException: RESTEASY007550: " "Unable to get boundary for multipart" ) - assert response.text == expected_text + 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", cache_control=None, @@ -409,7 +443,7 @@ def test_bogus_boundary( "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, @@ -417,12 +451,20 @@ def test_bogus_boundary( timeout=30, ) - handle_server_errors(response=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(), + ) + handle_server_errors(response=vws_response) expected_text = "No image." - assert response.text == expected_text + assert requests_response.text == expected_text assert_vwq_failure( - response=response, + response=vws_response, status_code=HTTPStatus.BAD_REQUEST, content_type="application/json", cache_control=None, @@ -465,7 +507,7 @@ def test_extra_section( "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, @@ -473,9 +515,18 @@ def test_extra_section( timeout=30, ) - handle_server_errors(response=response) - 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(), + ) + 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") @@ -508,7 +559,9 @@ def test_match_exact( """ image_file = high_quality_image image_content = image_file.getvalue() - metadata_encoded = base64.b64encode(s=b"example").decode("ascii") + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) name = "example_name" target_id = vws_client.add_target( @@ -519,7 +572,7 @@ def test_match_exact( 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) @@ -528,7 +581,8 @@ def test_match_exact( response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - (result,) = response.json()["results"] + response_json = json.loads(s=response.text) + (result,) = response_json["results"] assert result == { "target_id": target_id, "target_data": { @@ -553,7 +607,9 @@ def test_low_quality_image( results are returned. """ image_file = image_file_success_state_low_rating - metadata_encoded = base64.b64encode(s=b"example").decode("ascii") + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) name = "example_name" target_id = vws_client.add_target( @@ -579,7 +635,9 @@ def test_match_similar( If a similar image to one that was added is queried for, target data is shown. """ - metadata_encoded = base64.b64encode(s=b"example").decode("ascii") + metadata_encoded = base64.b64encode(s=b"example").decode( + encoding="ascii" + ) name_matching = "example_name_matching" name_not_matching = "example_name_not_matching" @@ -603,10 +661,10 @@ def test_match_similar( vws_client.wait_for_target_processed(target_id=target_id_not_matching) similar_image_buffer = io.BytesIO() - similar_image_data = copy.copy(high_quality_image) + 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(similar_image_buffer, format="JPEG") + pil_similar_image.save(fp=similar_image_buffer, format="JPEG") (matching_target,) = cloud_reco_client.query( image=similar_image_buffer, @@ -658,7 +716,7 @@ def test_not_base64_encoded_processable( len(not_base64_encoded_processable) % 4 ] expected_metadata = base64.b64encode( - base64.b64decode(s=expected_metadata_original), + s=base64.b64decode(s=expected_metadata_original), ) assert query_metadata == expected_metadata.decode() @@ -716,8 +774,7 @@ def test_extra_fields( def test_missing_image_and_extra_fields( vuforia_database: VuforiaDatabase, ) -> 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. @@ -780,20 +837,20 @@ def test_default( 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 @staticmethod - @pytest.mark.parametrize("num_results", [1, b"1", 50]) + @pytest.mark.parametrize(argnames="num_results", argvalues=[1, b"1", 50]) def test_valid_accepted( high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, 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. @@ -811,7 +868,8 @@ def test_valid_accepted( 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( @@ -837,15 +895,14 @@ def test_valid_works( assert len(result) == max_num_results @staticmethod - @pytest.mark.parametrize("num_results", [-1, 0, 51]) + @pytest.mark.parametrize(argnames="num_results", argvalues=[-1, 0, 51]) def test_out_of_range( high_quality_image: io.BytesIO, 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://developer.vuforia.com/library/web-api/vuforia-query-web-api. @@ -853,7 +910,7 @@ def test_out_of_range( maximum. """ with pytest.raises( - expected_exception=MaxNumResultsOutOfRange, + expected_exception=MaxNumResultsOutOfRangeError, ) as exc_info: cloud_reco_client.query( image=high_quality_image, @@ -876,20 +933,19 @@ def test_out_of_range( @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( high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, 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 = { @@ -923,7 +979,7 @@ def _add_and_wait_for_targets( """ Add targets with the given image. """ - target_ids: set[str] = set() + target_ids: Iterable[str] = set() for _ in range(num_targets): target_id = vws_client.add_target( name=uuid.uuid4().hex, @@ -932,7 +988,7 @@ def _add_and_wait_for_targets( active_flag=True, application_metadata=None, ) - target_ids.add(target_id) + target_ids = {*target_ids, target_id} for created_target_id in target_ids: vws_client.wait_for_target_processed(target_id=created_target_id) @@ -967,12 +1023,16 @@ def test_default( response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()["results"] + 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 @staticmethod - @pytest.mark.parametrize("include_target_data", ["top", "TOP"]) + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["top", "TOP"], + ) def test_top( high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, @@ -998,12 +1058,16 @@ def test_top( response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()["results"] + 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 @staticmethod - @pytest.mark.parametrize("include_target_data", ["none", "NONE"]) + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["none", "NONE"], + ) def test_none( high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, @@ -1029,12 +1093,16 @@ def test_none( response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()["results"] + 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 @staticmethod - @pytest.mark.parametrize("include_target_data", ["all", "ALL"]) + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["all", "ALL"], + ) def test_all( high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, @@ -1060,12 +1128,16 @@ def test_all( response = _query(vuforia_database=vuforia_database, body=body) assert_query_success(response=response) - result_1, result_2 = response.json()["results"] + 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 @staticmethod - @pytest.mark.parametrize("include_target_data", ["a", True, 0]) + @pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["a", True, 0], + ) def test_invalid_value( high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, @@ -1107,8 +1179,8 @@ class TestAcceptHeader: @staticmethod @pytest.mark.parametrize( - "extra_headers", - [ + argnames="extra_headers", + argvalues=[ { "Accept": "application/json", }, @@ -1148,7 +1220,7 @@ def test_valid( "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, @@ -1156,9 +1228,18 @@ def test_valid( timeout=30, ) - handle_server_errors(response=response) - 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(), + ) + 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( @@ -1196,7 +1277,7 @@ def test_invalid( "Accept": "text/html", } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, @@ -1204,10 +1285,18 @@ def test_invalid( timeout=30, ) - handle_server_errors(response=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(), + ) + 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, @@ -1269,7 +1358,7 @@ def test_not_image(cloud_reco_client: CloudRecoService) -> None: """ not_image_data = b"not_image_data" - with pytest.raises(expected_exception=BadImage) as exc_info: + with pytest.raises(expected_exception=BadImageError) as exc_info: cloud_reco_client.query( image=io.BytesIO(initial_bytes=not_image_data) ) @@ -1305,16 +1394,7 @@ class TestMaximumImageFileSize: """ @staticmethod - @pytest.mark.skipif( - sys.version_info > (3, 9), - reason=( - "There is a bug in urllib3: " - "https://github.com/urllib3/urllib3/issues/2733" - ), - ) - def test_png( - cloud_reco_client: CloudRecoService, - ) -> None: # pragma: no cover + def test_png(cloud_reco_client: CloudRecoService) -> None: """ According to https://developer.vuforia.com/library/web-api/vuforia-query-web-api. @@ -1367,7 +1447,7 @@ def test_png( assert (image_content_size * 0.95) < max_bytes with pytest.raises( - expected_exception=RequestEntityTooLarge + expected_exception=RequestEntityTooLargeError ) as exc_info: cloud_reco_client.query(image=png_too_large) @@ -1384,16 +1464,7 @@ def test_png( assert response.text == _NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR @staticmethod - @pytest.mark.skipif( - sys.version_info > (3, 9), - reason=( - "There is a bug in urllib3: " - "https://github.com/urllib3/urllib3/issues/2733" - ), - ) - def test_jpeg( - cloud_reco_client: CloudRecoService, - ) -> None: # pragma: no cover + def test_jpeg(cloud_reco_client: CloudRecoService) -> None: """ According to https://developer.vuforia.com/library/web-api/vuforia-query-web-api. @@ -1446,7 +1517,7 @@ def test_jpeg( assert (image_content_size * 0.95) < max_bytes with pytest.raises( - expected_exception=RequestEntityTooLarge + expected_exception=RequestEntityTooLargeError ) as exc_info: cloud_reco_client.query(image=jpeg_too_large) @@ -1497,7 +1568,7 @@ def test_max_height( height=max_height + 1, ) - with pytest.raises(expected_exception=BadImage) as exc_info: + with pytest.raises(expected_exception=BadImageError) as exc_info: cloud_reco_client.query(image=png_too_tall) response = exc_info.value.response @@ -1513,7 +1584,6 @@ def test_max_height( 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) # The separators are inconsistent and we test this. @@ -1550,7 +1620,7 @@ def test_max_width(cloud_reco_client: CloudRecoService) -> None: height=height, ) - with pytest.raises(expected_exception=BadImage) as exc_info: + with pytest.raises(expected_exception=BadImageError) as exc_info: result = cloud_reco_client.query(image=png_too_wide) response = exc_info.value.response @@ -1602,7 +1672,7 @@ class TestImageFormats: """ @staticmethod - @pytest.mark.parametrize("file_format", ["png", "jpeg"]) + @pytest.mark.parametrize(argnames="file_format", argvalues=["png", "jpeg"]) def test_supported( high_quality_image: io.BytesIO, file_format: str, @@ -1613,7 +1683,7 @@ def test_supported( """ image_buffer = io.BytesIO() pil_image = Image.open(fp=high_quality_image) - pil_image.save(image_buffer, file_format) + 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) @@ -1631,10 +1701,10 @@ def test_unsupported( file_format = "tiff" image_buffer = io.BytesIO() pil_image = Image.open(fp=high_quality_image) - pil_image.save(image_buffer, file_format) + pil_image.save(fp=image_buffer, format=file_format) image_content = image_buffer.getvalue() - with pytest.raises(expected_exception=BadImage) as exc_info: + with pytest.raises(expected_exception=BadImageError) as exc_info: cloud_reco_client.query( image=io.BytesIO(initial_bytes=image_content) ) @@ -1670,7 +1740,7 @@ class TestProcessing: """ @staticmethod - @pytest.mark.parametrize("active_flag", [True, False]) + @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_processing( high_quality_image: io.BytesIO, vws_client: VWS, @@ -1722,13 +1792,15 @@ def test_updated_target( 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. """ metadata = b"example_metadata" - metadata_encoded = base64.b64encode(s=metadata).decode("ascii") + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) name = "example_name" target_id = vws_client.add_target( name=name, @@ -1738,13 +1810,15 @@ 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_name = name + "2" new_metadata = metadata + b"2" - new_metadata_encoded = base64.b64encode(s=new_metadata).decode("ascii") + new_metadata_encoded = base64.b64encode(s=new_metadata).decode( + encoding="ascii" + ) results = cloud_reco_client.query(image=high_quality_image) (result,) = results @@ -1758,7 +1832,7 @@ 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) @@ -1817,7 +1891,7 @@ def test_deleted_active( # # We retry to allow for this difference. for attempt in Retrying( - wait=wait_fixed(0.1), + wait=wait_fixed(wait=0.1), stop=stop_after_delay(max_delay=3), retry=retry_if_exception_type( exception_types=(AssertionError,), @@ -1881,8 +1955,7 @@ def test_status_failed( @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 @@ -1897,15 +1970,15 @@ class TestDateFormats: @staticmethod @pytest.mark.parametrize( - "datetime_format", - [ + 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( high_quality_image: io.BytesIO, vuforia_database: VuforiaDatabase, @@ -1913,11 +1986,10 @@ def test_date_formats( *, 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")} @@ -1927,7 +1999,7 @@ def test_date_formats( gmt = ZoneInfo(key="GMT") now = datetime.datetime.now(tz=gmt) - date = now.strftime(datetime_format) + date = now.strftime(format=datetime_format) request_path = "/v1/query" content, content_type_header = encode_multipart_formdata(fields=body) method = HTTPMethod.POST @@ -1950,7 +2022,7 @@ def test_date_formats( "Content-Type": content_type_header, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=VWQ_HOST, url=request_path), headers=headers, @@ -1958,9 +2030,18 @@ def test_date_formats( timeout=30, ) - handle_server_errors(response=response) - 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(), + ) + 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") @@ -1977,7 +2058,9 @@ def test_inactive_project( """ If the project is inactive, a FORBIDDEN response is returned. """ - with pytest.raises(expected_exception=InactiveProject) as exc_info: + with pytest.raises( + expected_exception=InactiveProjectError + ) as exc_info: inactive_cloud_reco_client.query(image=high_quality_image) response = exc_info.value.response diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index c09311e2e..3cb3471f6 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -14,11 +14,10 @@ from beartype import beartype from freezegun import freeze_time from PIL import Image -from requests.exceptions import MissingSchema from vws import VWS, CloudRecoService from vws_auth_tools import rfc_1123_date -from mock_vws import MockVWS +from mock_vws import MissingSchemeError, MockVWS from mock_vws.database import VuforiaDatabase from mock_vws.image_matchers import ExactMatcher, StructuralSimilarityMatcher from mock_vws.target import Target @@ -33,14 +32,15 @@ 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.""" + """ + 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 @@ -80,8 +80,8 @@ class TestRealHTTP: @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. + By default, the mock stops any requests made with `requests` to non- + Vuforia addresses, but not to mocked Vuforia endpoints. """ with MockVWS(): with pytest.raises( @@ -240,21 +240,21 @@ def test_no_scheme() -> None: """ An error if raised if a URL is given with no scheme. """ - with pytest.raises(expected_exception=MissingSchema) as vws_exc: + with pytest.raises(expected_exception=MissingSchemeError) as vws_exc: MockVWS(base_vws_url="vuforia.vws.example.com") expected = ( 'Invalid URL "vuforia.vws.example.com": No scheme supplied. ' 'Perhaps you meant "https://vuforia.vws.example.com".' ) - assert str(vws_exc.value) == expected - with pytest.raises(expected_exception=MissingSchema) as vwq_exc: + 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(vwq_exc.value) == expected + assert str(object=vwq_exc.value) == expected class TestTargets: @@ -376,7 +376,12 @@ def test_date_changes() -> None: The date that the response is sent is in the response Date header. """ new_year = 2012 - new_time = datetime.datetime(new_year, 1, 1, tzinfo=datetime.UTC) + 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", @@ -445,18 +450,22 @@ def test_duplicate_keys() -> None: (bad_database_name_db, database_name_conflict_error), ): with pytest.raises( - ValueError, + expected_exception=ValueError, match=expected_message + "$", ): mock.add_database(database=bad_database) class TestQueryImageMatchers: - """Tests for query image matchers.""" + """ + 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.""" + """ + The exact matcher matches only exactly the same images. + """ database = VuforiaDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -469,7 +478,7 @@ def test_exact_match(high_quality_image: io.BytesIO) -> None: pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") with MockVWS(query_match_checker=ExactMatcher()) as mock: mock.add_database(database=database) @@ -492,7 +501,9 @@ def test_exact_match(high_quality_image: io.BytesIO) -> None: @staticmethod def test_custom_matcher(high_quality_image: io.BytesIO) -> None: - """It is possible to use a custom matcher.""" + """ + It is possible to use a custom matcher. + """ database = VuforiaDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -505,7 +516,7 @@ def test_custom_matcher(high_quality_image: io.BytesIO) -> None: pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") with MockVWS(query_match_checker=_not_exact_matcher) as mock: mock.add_database(database=database) @@ -531,7 +542,9 @@ def test_structural_similarity_matcher( high_quality_image: io.BytesIO, different_high_quality_image: io.BytesIO, ) -> None: - """The structural similarity matcher matches similar images.""" + """ + The structural similarity matcher matches similar images. + """ database = VuforiaDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -544,7 +557,7 @@ def test_structural_similarity_matcher( pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") with MockVWS( query_match_checker=StructuralSimilarityMatcher(), @@ -574,11 +587,15 @@ def test_structural_similarity_matcher( class TestDuplicatesImageMatchers: - """Tests for duplicates image matchers.""" + """ + 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.""" + """ + The exact matcher matches only exactly the same images. + """ database = VuforiaDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -587,7 +604,7 @@ def test_exact_match(high_quality_image: io.BytesIO) -> None: pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") with MockVWS(duplicate_match_checker=ExactMatcher()) as mock: mock.add_database(database=database) @@ -622,7 +639,9 @@ def test_exact_match(high_quality_image: io.BytesIO) -> None: @staticmethod def test_custom_matcher(high_quality_image: io.BytesIO) -> None: - """It is possible to use a custom matcher.""" + """ + It is possible to use a custom matcher. + """ database = VuforiaDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -631,7 +650,7 @@ def test_custom_matcher(high_quality_image: io.BytesIO) -> None: pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") with MockVWS(duplicate_match_checker=_not_exact_matcher) as mock: mock.add_database(database=database) @@ -668,7 +687,9 @@ def test_custom_matcher(high_quality_image: io.BytesIO) -> None: def test_structural_similarity_matcher( high_quality_image: io.BytesIO, ) -> None: - """The structural similarity matcher matches similar images.""" + """ + The structural similarity matcher matches similar images. + """ database = VuforiaDatabase() vws_client = VWS( server_access_key=database.server_access_key, @@ -677,7 +698,7 @@ def test_structural_similarity_matcher( pil_image = Image.open(fp=high_quality_image) re_exported_image = io.BytesIO() - pil_image.save(re_exported_image, format="PNG") + pil_image.save(fp=re_exported_image, format="PNG") with MockVWS( duplicate_match_checker=StructuralSimilarityMatcher(), @@ -716,18 +737,22 @@ def test_text(endpoint: Endpoint) -> None: """ It is possible to send strings to VWS endpoints. """ - session = requests.Session() - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc - if endpoint.prepared_request.body is None: - endpoint.prepared_request.body = b"" + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": pytest.skip() - assert isinstance(endpoint.prepared_request.body, bytes) - endpoint.prepared_request.body = endpoint.prepared_request.body.decode( - encoding="utf-8", + 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 = session.send(request=endpoint.prepared_request) - response.raise_for_status() + response = new_endpoint.send() + assert response.status_code == endpoint.successful_headers_status_code diff --git a/tests/mock_vws/test_target_raters.py b/tests/mock_vws/test_target_raters.py index b2120955c..dda0859df 100644 --- a/tests/mock_vws/test_target_raters.py +++ b/tests/mock_vws/test_target_raters.py @@ -33,7 +33,7 @@ def test_random_target_tracking_rater() -> None: assert lowest_rating != highest_rating -@pytest.mark.parametrize("rating", range(-10, 10)) +@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. diff --git a/tests/mock_vws/test_target_summary.py b/tests/mock_vws/test_target_summary.py index 4a87d73ac..c432e78b4 100644 --- a/tests/mock_vws/test_target_summary.py +++ b/tests/mock_vws/test_target_summary.py @@ -9,7 +9,7 @@ import pytest 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 @@ -22,7 +22,7 @@ class TestTargetSummary: """ @staticmethod - @pytest.mark.parametrize("active_flag", [True, False]) + @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_target_summary( vws_client: VWS, vuforia_database: VuforiaDatabase, @@ -81,9 +81,8 @@ def test_after_processing( 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,7 +94,7 @@ 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", @@ -168,7 +167,7 @@ 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=UnknownTarget): + 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_unexpected_json.py b/tests/mock_vws/test_unexpected_json.py index 6ca01a0f7..c7f96856e 100644 --- a/tests/mock_vws/test_unexpected_json.py +++ b/tests/mock_vws/test_unexpected_json.py @@ -7,7 +7,6 @@ from urllib.parse import urlparse import pytest -import requests from vws_auth_tools import authorization_header, rfc_1123_date from tests.mock_vws.utils import Endpoint @@ -28,7 +27,7 @@ def test_does_not_take_data(endpoint: Endpoint) -> None: responses. """ if ( - endpoint.prepared_request.headers.get( + endpoint.headers.get( "Content-Type", ) == "application/json" @@ -41,29 +40,38 @@ def test_does_not_take_data(endpoint: Endpoint) -> None: authorization_string = authorization_header( access_key=endpoint.access_key, secret_key=endpoint.secret_key, - method=endpoint.prepared_request.method or "", + method=endpoint.method, content=content, content_type=content_type, date=date, - request_path=endpoint.prepared_request.path_url, + request_path=endpoint.path_url, ) - endpoint.prepared_request.headers.update( - { - "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)), + } + + 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, ) - endpoint.prepared_request.body = content - endpoint.prepared_request.prepare_content_length(body=content) - session = requests.Session() - response = session.send(request=endpoint.prepared_request) + response = new_endpoint.send() + handle_server_errors(response=response) - url = endpoint.prepared_request.url or "" - netloc = urlparse(url=url).netloc + netloc = urlparse(url=endpoint.base_url).netloc if netloc == "cloudreco.vuforia.com": # The multipart/formdata boundary is no longer in the given # content. diff --git a/tests/mock_vws/test_update_target.py b/tests/mock_vws/test_update_target.py index 93cc176b3..4cf4911c7 100644 --- a/tests/mock_vws/test_update_target.py +++ b/tests/mock_vws/test_update_target.py @@ -8,47 +8,44 @@ import uuid from http import HTTPMethod, HTTPStatus from typing import Any, Final -from urllib.parse import urljoin import pytest -import requests from vws import VWS +from vws.exceptions.base_exceptions import VWSError from vws.exceptions.vws_exceptions import ( - BadImage, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectInactive, - TargetNameExist, + 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.types 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, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors _MAX_METADATA_BYTES: Final[int] = 1024 * 1024 - 1 def _update_target( *, - vuforia_database: VuforiaDatabase, + vws_client: VWS, data: dict[str, Any], target_id: str, content_type: str = "application/json", -) -> requests.Response: - """ - Make a request to the endpoint to update a target. +) -> Response: + """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. @@ -56,38 +53,15 @@ def _update_target( Returns: The response returned by the API. """ - date = rfc_1123_date() - request_path = "/targets/" + target_id - content = json.dumps(obj=data).encode(encoding="utf-8") - - authorization_string = authorization_header( - access_key=vuforia_database.server_access_key, - secret_key=vuforia_database.server_secret_key, + return vws_client.make_request( method=HTTPMethod.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=HTTPMethod.PUT, - url=urljoin("https://vws.vuforia.com/", request_path), - headers=headers, data=content, - timeout=30, + request_path=f"/targets/{target_id}", + expected_result_code=ResultCodes.SUCCESS.value, + content_type=content_type, ) - handle_server_errors(response=response) - return response - @pytest.mark.usefixtures("verify_mock_vuforia") class TestUpdate: @@ -97,8 +71,8 @@ class TestUpdate: @staticmethod @pytest.mark.parametrize( - "content_type", - [ + argnames="content_type", + argvalues=[ # This is the documented required content type: "application/json", # Other content types also work. @@ -107,7 +81,6 @@ class TestUpdate: ids=["Documented Content-Type", "Undocumented Content-Type"], ) def test_content_types( - vuforia_database: VuforiaDatabase, vws_client: VWS, image_file_failed_state: io.BytesIO, content_type: str, @@ -124,23 +97,25 @@ def test_content_types( 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( - vuforia_database: VuforiaDatabase, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: @@ -156,22 +131,24 @@ def test_empty_content_type( 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( - vuforia_database: VuforiaDatabase, vws_client: VWS, target_id: str, ) -> None: @@ -181,7 +158,7 @@ def test_no_fields_given( vws_client.wait_for_target_processed(target_id=target_id) response = _update_target( - vuforia_database=vuforia_database, + vws_client=vws_client, data={}, target_id=target_id, ) @@ -192,7 +169,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. @@ -212,7 +191,6 @@ class TestUnexpectedData: @staticmethod def test_invalid_extra_data( - vuforia_database: VuforiaDatabase, vws_client: VWS, target_id: str, ) -> None: @@ -221,14 +199,15 @@ def test_invalid_extra_data( """ 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, ) @@ -242,12 +221,11 @@ class TestWidth: @staticmethod @pytest.mark.parametrize( - "width", - [-1, "10", None, 0], + argnames="width", + argvalues=[-1, "10", None, 0], ids=["Negative", "Wrong Type", "None", "Zero"], ) def test_width_invalid( - vuforia_database: VuforiaDatabase, vws_client: VWS, width: int | str | None, target_id: str, @@ -260,14 +238,15 @@ def test_width_invalid( 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, ) @@ -295,8 +274,14 @@ class TestActiveFlag: """ @staticmethod - @pytest.mark.parametrize("initial_active_flag", [True, False]) - @pytest.mark.parametrize("desired_active_flag", [True, False]) + @pytest.mark.parametrize( + argnames="initial_active_flag", + argvalues=[True, False], + ) + @pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=[True, False], + ) def test_active_flag( vws_client: VWS, image_file_success_state_low_rating: io.BytesIO, @@ -325,9 +310,11 @@ def test_active_flag( assert target_details.target_record.active_flag == desired_active_flag @staticmethod - @pytest.mark.parametrize("desired_active_flag", ["string", None]) + @pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=["string", None], + ) def test_invalid( - vuforia_database: VuforiaDatabase, vws_client: VWS, target_id: str, desired_active_flag: str | None, @@ -337,14 +324,15 @@ def test_invalid( """ 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, ) @@ -358,8 +346,8 @@ class TestApplicationMetadata: @staticmethod @pytest.mark.parametrize( - "metadata", - [ + argnames="metadata", + argvalues=[ b"a", b"a" * _MAX_METADATA_BYTES, ], @@ -373,7 +361,9 @@ def test_base64_encoded( """ A base64 encoded string is valid application metadata. """ - metadata_encoded = base64.b64encode(s=metadata).decode("ascii") + 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, @@ -381,9 +371,8 @@ def test_base64_encoded( ) @staticmethod - @pytest.mark.parametrize("invalid_metadata", [1, None]) + @pytest.mark.parametrize(argnames="invalid_metadata", argvalues=[1, None]) def test_invalid_type( - vuforia_database: VuforiaDatabase, vws_client: VWS, target_id: str, invalid_metadata: int | None, @@ -393,14 +382,15 @@ def test_invalid_type( """ 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, ) @@ -434,7 +424,7 @@ def test_not_base64_encoded_not_processable( """ vws_client.wait_for_target_processed(target_id=target_id) - with pytest.raises(expected_exception=Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.update_target( target_id=target_id, application_metadata=not_base64_encoded_not_processable, @@ -453,10 +443,12 @@ def test_metadata_too_large(vws_client: VWS, target_id: str) -> None: for application metadata. """ metadata = b"a" * (_MAX_METADATA_BYTES + 1) - metadata_encoded = base64.b64encode(s=metadata).decode("ascii") + metadata_encoded = base64.b64encode(s=metadata).decode( + encoding="ascii" + ) vws_client.wait_for_target_processed(target_id=target_id) - with pytest.raises(expected_exception=MetadataTooLarge) as exc: + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: vws_client.update_target( target_id=target_id, application_metadata=metadata_encoded, @@ -480,8 +472,8 @@ class TestTargetName: @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 @@ -496,8 +488,7 @@ def test_name_valid( 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. @@ -542,7 +533,6 @@ def test_name_valid( def test_name_invalid( name: str | int | None, target_id: str, - vuforia_database: VuforiaDatabase, vws_client: VWS, status_code: int, result_code: ResultCodes, @@ -552,14 +542,15 @@ def test_name_invalid( """ 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, ) @@ -594,7 +585,7 @@ 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) - with pytest.raises(expected_exception=TargetNameExist) as exc: + with pytest.raises(expected_exception=TargetNameExistError) as exc: vws_client.update_target( target_id=second_target_id, name=first_target_name, @@ -632,8 +623,7 @@ def test_same_name_given( @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. @@ -662,12 +652,12 @@ def test_bad_image_format_or_color_space( 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(expected_exception=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 @@ -691,8 +681,8 @@ def test_corrupted( @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 @@ -738,7 +728,7 @@ def test_image_too_large(target_id: str, vws_client: VWS) -> None: assert image_content_size < max_bytes assert (image_content_size * 1.05) > max_bytes - with pytest.raises(expected_exception=ImageTooLarge) as exc: + with pytest.raises(expected_exception=ImageTooLargeError) as exc: vws_client.update_target(target_id=target_id, image=png_too_large) assert_vws_failure( @@ -749,34 +739,33 @@ def test_image_too_large(target_id: str, vws_client: VWS) -> None: @staticmethod def test_not_base64_encoded_processable( - 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( - vuforia_database: VuforiaDatabase, vws_client: VWS, target_id: str, not_base64_encoded_not_processable: str, @@ -788,14 +777,15 @@ def test_not_base64_encoded_not_processable( """ 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, ) @@ -803,12 +793,12 @@ def test_not_base64_encoded_not_processable( @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. """ vws_client.wait_for_target_processed(target_id=target_id) - with pytest.raises(expected_exception=BadImage) as exc: + 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"), @@ -821,11 +811,13 @@ def test_not_image(target_id: str, vws_client: VWS) -> None: ) @staticmethod - @pytest.mark.parametrize("invalid_type_image", [1, None]) + @pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], + ) def test_invalid_type( invalid_type_image: int | None, target_id: str, - vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: """ @@ -833,14 +825,15 @@ def test_invalid_type( """ 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, ) @@ -851,12 +844,11 @@ def test_rating_can_change( high_quality_image: io.BytesIO, 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. """ target_id = vws_client.add_target( @@ -898,5 +890,5 @@ def test_inactive_project(inactive_vws_client: VWS) -> None: """ If the project is inactive, a FORBIDDEN response is returned. """ - with pytest.raises(expected_exception=ProjectInactive): + with pytest.raises(expected_exception=ProjectInactiveError): inactive_vws_client.update_target(target_id=uuid.uuid4().hex) diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 5b5e0615a..b3b92db61 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -4,64 +4,86 @@ import io import secrets +from collections.abc import Mapping +from dataclasses import dataclass from typing import Literal +from urllib.parse import urljoin import requests from PIL import Image +from requests.structures import CaseInsensitiveDict +from vws.types import Response from mock_vws._constants import ResultCodes +@dataclass(frozen=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 + base_url: str + path_url: str + method: str + headers: Mapping[str, str] + data: bytes | str successful_headers_result_code: ResultCodes 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: + def send(self) -> Response: """ - 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. + Send the 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(sep=";")[0] - self.auth_header_content_type: str = content_type - self.access_key = access_key - self.secret_key = secret_key + 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(), + ) + + @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] def make_image_file( @@ -70,8 +92,7 @@ def make_image_file( 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. @@ -86,7 +107,7 @@ 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)) + 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)) @@ -97,6 +118,6 @@ def make_image_file( value=(red, green, blue), ) - image.save(image_buffer, file_format) + 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 2a0dd2375..936e207bb 100644 --- a/tests/mock_vws/utils/assertions.py +++ b/tests/mock_vws/utils/assertions.py @@ -10,9 +10,8 @@ from string import hexdigits from zoneinfo import ZoneInfo -import requests from beartype import beartype -from vws.exceptions.response import Response +from vws.types import Response from mock_vws._constants import ResultCodes @@ -20,12 +19,11 @@ @beartype def assert_vws_failure( *, - response: requests.Response | Response, + 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. @@ -50,11 +48,10 @@ def assert_vws_failure( @beartype def assert_valid_date_header( *, - response: requests.Response | Response, + response: Response, ) -> None: - """ - Assert that a response includes a `Date` header which is within two minutes - of "now". + """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. @@ -85,10 +82,9 @@ def assert_valid_date_header( @beartype def assert_valid_transaction_id( *, - response: requests.Response | Response, + response: Response, ) -> None: - """ - Assert that a response includes a valid transaction ID. + """Assert that a response includes a valid transaction ID. Args: response: The response returned by a request to a Vuforia service. @@ -103,9 +99,8 @@ def assert_valid_transaction_id( @beartype -def assert_json_separators(*, response: requests.Response | Response) -> None: - """ - Assert that a JSON response is formatted correctly. +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. @@ -122,12 +117,11 @@ def assert_json_separators(*, response: requests.Response | Response) -> None: @beartype def assert_vws_response( *, - response: requests.Response | 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://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 @@ -160,7 +154,7 @@ def assert_vws_response( "x-envoy-upstream-service-time", } assert {str.lower(key) for key in response.headers} == response_header_keys - assert response.headers["Content-Length"] == str(len(response.text)) + 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" @@ -174,9 +168,8 @@ def assert_vws_response( @beartype -def assert_query_success(*, response: requests.Response) -> None: - """ - Assert that the given response is a success response for performing an +def assert_query_success(*, response: Response) -> None: + """Assert that the given response is a success response for performing an image recognition query. Raises: @@ -208,7 +201,7 @@ def assert_query_success(*, response: requests.Response) -> None: expected_response_header_not_chunked = { "Connection": "keep-alive", - "Content-Length": str(response.raw.tell()), + "Content-Length": str(object=response.tell_position), "Content-Type": "application/json", "Server": "nginx", } @@ -229,15 +222,14 @@ def assert_query_success(*, response: requests.Response) -> None: def assert_vwq_failure( *, - response: requests.Response | Response, + response: Response, status_code: int, content_type: str | None, cache_control: str | None, www_authenticate: str | None, connection: str, ) -> None: - """ - Assert that a VWQ failure response is as expected. + """Assert that a VWQ failure response is as expected. Args: response: The response returned by a request to VWQ. @@ -273,7 +265,7 @@ def assert_vwq_failure( # 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 = copy.copy(x=response_header_keys) response_header_keys_chunked.remove("Content-Length") response_header_keys_chunked.add("transfer-encoding") @@ -284,7 +276,9 @@ def assert_vwq_failure( 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["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 diff --git a/tests/mock_vws/utils/retries.py b/tests/mock_vws/utils/retries.py index f909459a3..6d7a7d491 100644 --- a/tests/mock_vws/utils/retries.py +++ b/tests/mock_vws/utils/retries.py @@ -1,14 +1,16 @@ -"""Helpers for retrying requests to VWS.""" +""" +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 ( - TooManyRequests, + TooManyRequestsError, ) -RETRY_EXCEPTIONS = (TooManyRequests, ServerError) +RETRY_EXCEPTIONS = (TooManyRequestsError, ServerError) # We rely on pytest-retry for exceptions *during* tests. # We use tenacity for exceptions *before* tests. diff --git a/tests/mock_vws/utils/too_many_requests.py b/tests/mock_vws/utils/too_many_requests.py index 2648751aa..2cb2b6863 100644 --- a/tests/mock_vws/utils/too_many_requests.py +++ b/tests/mock_vws/utils/too_many_requests.py @@ -4,30 +4,22 @@ from http import HTTPStatus -import requests from beartype import beartype from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.response import Response -from vws.exceptions.vws_exceptions import TooManyRequests +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.types import Response @beartype -def handle_server_errors(*, response: requests.Response) -> None: - """ - Raise errors if the response is a 429 or 5xx. - This is useful for retrying tests based on the exceptions they raise. +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.TooManyRequests: The response is a 429. + vws.exceptions.vws_exceptions.TooManyRequestsError: The response is a + 429. vws.exceptions.custom_exceptions.ServerError: The response is a 5xx. """ - vws_response = Response( - text=response.text, - url=response.url, - status_code=response.status_code, - headers=dict(response.headers), - request_body=response.request.body, - ) # We do not cover this because in some test runs we will not hit the # error. if ( @@ -35,11 +27,11 @@ def handle_server_errors(*, response: requests.Response) -> None: ): # 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 TooManyRequests(response=vws_response) + 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=vws_response) + raise ServerError(response=response)