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 b38df29f4..13b3964d1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,19 @@ +--- version: 2 + updates: - - package-ecosystem: "pip" - directory: "/" + - package-ecosystem: pip + directory: / schedule: - interval: "daily" + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 + + - package-ecosystem: pre-commit + directory: / + schedule: + interval: daily diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 000000000..3e5ceda64 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,37 @@ +--- +name: autofix.ci + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + autofix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + + - name: Run fixers + uses: j178/prek-action@v3.0.0 + with: + prek-version: 0.4.11 + extra-args: >- + --all-files --hook-stage pre-commit --no-fail-fast --verbose + env: + UV_NO_CACHE: '1' + UV_PYTHON: 3.14 + + - uses: autofix-ci/action@v1.3.4 + if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ab24d39b..21f12ba8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,50 +1,52 @@ --- - -name: CI +name: Test on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] schedule: # * is a special character in YAML so you have to quote this string # Run at 1:00 every day - - cron: '0 1 * * *' + - cron: 0 1 * * * + +permissions: {} jobs: build: - strategy: matrix: - python-version: [3.8] - platform: [ubuntu-latest] + python-version: ['3.14'] + platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v2 - - name: "Set up Python" - uses: actions/setup-python@v2 + - uses: actions/checkout@v7 with: - python-version: ${{ matrix.python-version }} + persist-credentials: false - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip setuptools wheel - # We use '--ignore-installed' to avoid GitHub's cache which can cause - # issues - we have seen packages from this cache be cause trouble with - # pip-extra-reqs. - python -m pip install --ignore-installed --upgrade --editable .[dev] - sudo apt-get install -y enchant - - - name: "Lint" - run: | - make lint + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' - - name: "Run tests" + - name: Run tests run: | - pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests tests/ --cov-report=xml - - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v1" + # We run tests against "." and not the tests directory as we test the README + # and documentation. + uv run --extra=dev --python=${{ matrix.python-version }} pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests/ . + + completion-ci: + needs: build + runs-on: ubuntu-latest + if: always() # Run even if one matrix job fails + steps: + - name: Check matrix job status + run: |- + if ! ${{ needs.build.result == 'success' }}; then + echo "One or more matrix jobs failed" + exit 1 + fi diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml new file mode 100644 index 000000000..69fa53039 --- /dev/null +++ b/.github/workflows/dependabot-merge.yml @@ -0,0 +1,19 @@ +--- + +name: Dependabot auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..a1c124821 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,57 @@ +--- +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # * is a special character in YAML so you have to quote this string + # Run at 1:00 every day + - cron: 0 1 * * * + +permissions: {} + +jobs: + build: + strategy: + matrix: + python-version: ['3.14'] + platform: [ubuntu-latest, windows-latest] + hook-stage: [pre-commit, pre-push, manual] + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Lint + uses: j178/prek-action@v3.0.0 + with: + prek-version: 0.4.11 + extra-args: >- + --all-files --hook-stage ${{ matrix.hook-stage }} --verbose + env: + UV_NO_CACHE: '1' + UV_PYTHON: ${{ matrix.python-version }} + + completion-lint: + needs: build + runs-on: ubuntu-latest + if: always() # Run even if one matrix job fails + steps: + - name: Check matrix job status + run: |- + if ! ${{ needs.build.result == 'success' }}; then + echo "One or more matrix jobs failed" + exit 1 + fi diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml new file mode 100644 index 000000000..fceb50805 --- /dev/null +++ b/.github/workflows/publish-site.yml @@ -0,0 +1,28 @@ +--- +name: Deploy documentation + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pages: + runs-on: ubuntu-latest + environment: + name: ${{ github.ref_name == 'main' && 'github-pages' || 'development' }} + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: sphinx-notes/pages@v3 + with: + documentation_path: docs/source + pyproject_extras: dev + python_version: '3.14' + sphinx_build_options: -W + cache: true + publish: ${{ github.ref_name == 'main' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..3337a2415 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +--- +name: Release + +on: workflow_dispatch + +jobs: + build: + name: Publish a release + runs-on: ubuntu-latest + + # Specifying an environment is strongly recommended by PyPI. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + environment: release + + permissions: + # This is needed for PyPI publishing. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + id-token: write + # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. + contents: write + + steps: + - uses: actions/checkout@v7 + with: # zizmor: ignore[artipacked] git-auto-commit-action requires credentials + # See + # https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches + token: ${{ secrets.RELEASE_PAT }} + # 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@v9.0.0 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Calver calculate version + uses: StephaneBour/actions-calver@master + id: calver + with: + date_format: '%Y.%m.%d' + release: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # towncrier writes the rendered notes to stdout (informational + # chatter goes to stderr), so this is the curated release body for + # this version, not github-tag-action's commit-derived changelog. + - name: Generate the GitHub release notes + env: + RELEASE: ${{ steps.calver.outputs.release }} + run: uv run --extra=release towncrier build --draft --version "$RELEASE" > + release-notes.md + + # Assemble the same fragments into CHANGELOG.rst under a new + # ``$RELEASE`` section and delete the consumed fragment files. + - name: Update the changelog + env: + RELEASE: ${{ steps.calver.outputs.release }} + run: uv run --extra=release towncrier build --yes --version "$RELEASE" + + - uses: stefanzweifel/git-auto-commit-action@v7 + id: commit + with: + commit_message: Bump CHANGELOG + file_pattern: CHANGELOG.rst newsfragments + # Error if there are no changes. + skip_dirty_check: true + + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + custom_tag: ${{ steps.calver.outputs.release }} + tag_prefix: '' + commit_sha: ${{ steps.commit.outputs.commit_hash }} + + - name: Create a GitHub release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + makeLatest: true + name: Release ${{ steps.tag_version.outputs.new_tag }} + bodyFile: release-notes.md + + - name: Build a binary wheel and a source tarball + env: + NEW_TAG: ${{ steps.tag_version.outputs.new_tag }} + run: | + git fetch --tags + git checkout "$NEW_TAG" + uv build --sdist --wheel --out-dir dist/ + uv run --extra=release check-wheel-contents dist/*.whl + + - name: Publish distribution 📦 to PyPI + # 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. + uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml deleted file mode 100644 index 872f9a0fd..000000000 --- a/.github/workflows/windows-ci.yml +++ /dev/null @@ -1,42 +0,0 @@ ---- - -name: Windows CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - schedule: - # * is a special character in YAML so you have to quote this string - # Run at 1:00 every day - - cron: '0 1 * * *' - -jobs: - build: - - strategy: - matrix: - python-version: [3.8] - platform: [windows-latest] - - runs-on: ${{ matrix.platform }} - - steps: - - uses: actions/checkout@v2 - - name: "Set up Python" - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip setuptools wheel - # We use '--ignore-installed' to avoid GitHub's cache which can cause - # issues - we have seen packages from this cache be cause trouble with - # pip-extra-reqs. - python -m pip install --ignore-installed --upgrade --editable .[dev] - - - name: "Run tests" - run: | - pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests tests/ --cov-report=xml diff --git a/.gitignore b/.gitignore index ec1645423..1b5882e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -98,8 +98,9 @@ secrets.tar # mypy .mypy_cache/ -# macOS attributes -*.DS_Store +# Ignore Mac DS_Store files +.DS_Store +**/.DS_Store # pyre .pyre/ @@ -109,3 +110,8 @@ secrets.tar # setuptools_scm src/*/_setuptools_scm_version.txt + +uv.lock + +# Vale styles downloaded by ``vale sync`` +styles/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..0bca59790 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,457 @@ +--- +fail_fast: true + +.uv_version: &uv_version uv==0.11.7 + +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +default_install_hook_types: [pre-commit, pre-push] + +repos: + - repo: meta + hooks: + - id: check-useless-excludes + stages: [pre-commit] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + stages: [pre-commit] + - id: check-case-conflict + stages: [pre-commit] + - id: check-executables-have-shebangs + stages: [pre-commit] + - id: check-merge-conflict + stages: [pre-commit] + - id: check-shebang-scripts-are-executable + stages: [pre-commit] + - id: check-symlinks + stages: [pre-commit] + - id: check-json + stages: [pre-commit] + - id: check-toml + stages: [pre-commit] + - id: check-vcs-permalinks + stages: [pre-commit] + - id: check-yaml + stages: [pre-commit] + - id: end-of-file-fixer + stages: [pre-commit] + - id: file-contents-sorter + files: spelling_private_dict\.txt$ + stages: [pre-commit] + - id: trailing-whitespace + stages: [pre-commit] + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: rst-directive-colons + stages: [pre-commit] + - id: rst-inline-touching-normal + stages: [pre-commit] + - id: text-unicode-replacement-char + stages: [pre-commit] + - id: rst-backticks + + stages: [pre-commit] + - repo: local + hooks: + - id: actionlint + name: actionlint + entry: uv run --extra=dev actionlint + language: python + pass_filenames: false + types_or: [yaml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pydocstringformatter + name: pydocstringformatter + entry: uv run --extra=dev pydocstringformatter + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shellcheck + name: shellcheck + entry: uv run --extra=dev shellcheck --shell=bash + language: python + types_or: [shell] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shellcheck-docs + name: shellcheck-docs + entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=shell + --language=console --command="shellcheck --shell=bash" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shfmt + name: shfmt + entry: uv run --extra=dev shfmt --write --space-redirects --indent=4 + language: python + types_or: [shell] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: shfmt-docs + name: shfmt-docs + entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt + --no-pad-file --command="shfmt --write --space-redirects --indent=4" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: mypy + name: mypy + stages: [pre-push] + entry: uv run --extra=dev -m mypy --num-workers=4 + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + # We do not use --example-workers 0 due to https://github.com/python/mypy/issues/18283 + - id: mypy-docs + name: mypy-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="mypy + --num-workers=4" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + + - id: check-manifest + name: check-manifest + stages: [pre-push] + entry: uv run --extra=dev -m check_manifest + language: python + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pyright + name: pyright + stages: [pre-push] + entry: uv run --extra=dev -m pyright . + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pyright-docs + name: pyright-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python + --command="pyright" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + + - id: vulture + name: vulture + entry: uv run --extra=dev -m vulture . + language: python + types_or: [python] + pass_filenames: false + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: vulture-docs + name: vulture docs + entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python + --command="vulture" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pyroma + name: pyroma + entry: uv run --extra=dev -m pyroma --min 10 . + language: python + pass_filenames: false + types_or: [toml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: deptry + name: deptry + entry: uv run --extra=dev -m deptry src/ + language: python + pass_filenames: false + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pylint + name: pylint + entry: uv run --extra=dev -m pylint *.py src/ tests/ docs/ + language: python + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pylint-docs + name: pylint-docs + entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python + --command="pylint" + language: python + stages: [manual] + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + + - id: ruff-check-fix + name: Ruff check fix + entry: uv run --extra=dev -m ruff check --fix + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: ruff-check-fix-docs + name: Ruff check fix docs + entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: ruff-format-fix + name: Ruff format + entry: uv run --extra=dev -m ruff format + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: ruff-format-fix-docs + name: Ruff format docs + entry: uv run --extra=dev doccmd --language=python --no-pad-file --command="ruff + format" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: strict-kwargs-fix + name: strict-kwargs + entry: uv run --extra=dev strict-kwargs check --fix + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + require_serial: true + + - id: no-defaults + name: no-defaults + entry: uv run --extra=dev no-defaults + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + require_serial: true + + - id: doc8 + name: doc8 + entry: uv run --extra=dev -m doc8 + language: python + types_or: [rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + # Vale enforces prose style rules, such as banning em dashes, in + # reStructuredText and Markdown files. + # The rules come from the ``ai-tells`` package pinned in ``.vale.ini``, + # which ``vale sync`` downloads into the (gitignored) ``styles`` + # directory. + # Vale needs ``rst2html`` from Docutils on the ``PATH`` to parse + # reStructuredText. + # ``vale sync`` also runs when ``.vale.ini`` changes, so a package + # bump takes effect without waiting for the next reStructuredText + # change. + - id: vale-sync + name: vale sync + entry: uv run --extra=dev vale sync + language: python + pass_filenames: false + files: (\.(rst|md)$|^\.vale\.ini$) + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: vale + name: vale + entry: uv run --extra=dev vale + language: python + types_or: [rst, markdown] + require_serial: true + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: interrogate + name: interrogate + entry: uv run --extra=dev -m interrogate + language: python + types_or: [python] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: interrogate-docs + name: interrogate docs + entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python + --command="interrogate" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pyproject-fmt-fix + name: pyproject-fmt + entry: uv run --extra=dev pyproject-fmt + language: python + types_or: [toml] + files: pyproject.toml + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: linkcheck + name: linkcheck + entry: uv run --extra=dev sphinx-build -M linkcheck docs/source docs/build + -W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: spelling + name: spelling + entry: uv run --extra=dev sphinx-build -M spelling docs/source docs/build + -W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: docs + name: Build Documentation + entry: uv run --extra=dev sphinx-build -M html docs/source docs/build -W + language: python + stages: [manual] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pyright-verifytypes + name: pyright-verifytypes + stages: [pre-push] + entry: uv run --extra=dev -m pyright --verifytypes vws + language: python + pass_filenames: false + types_or: [python] + additional_dependencies: + - *uv_version + + - id: ty + name: ty + stages: [pre-push] + entry: uv run --extra=dev ty check + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: ty-docs + name: ty-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python + --command="ty check" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version + + - id: yamlfix + name: yamlfix + entry: uv run --extra=dev yamlfix + language: python + types_or: [yaml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: zizmor + name: zizmor + entry: uv run --extra=dev zizmor --strict-collection .github + language: python + pass_filenames: false + types_or: [yaml] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: sphinx-lint + name: sphinx-lint + entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long + language: python + types_or: [rst] + additional_dependencies: + - *uv_version + stages: [pre-commit] + + - id: pyrefly + name: pyrefly + stages: [pre-push] + entry: uv run --extra=dev pyrefly check + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: + - *uv_version + + - id: pyrefly-docs + name: pyrefly-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python + --command="pyrefly check" + language: python + types_or: [markdown, rst] + additional_dependencies: + - *uv_version diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..4a36aae89 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "overrides": [ + { + "files": ["*.yaml", "*.yml"], + "options": { + "singleQuote": true, + "printWidth": 100 + } + } + ] +} diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 000000000..250e9b2dd --- /dev/null +++ b/.vale.ini @@ -0,0 +1,15 @@ +StylesPath = styles +MinAlertLevel = error + +Packages = https://github.com/tbhb/vale-ai-tells/releases/download/v1.29.0/ai-tells.zip + +[*.{rst,md}] +BasedOnStyles = ai-tells + +# These rules misclassify established technical, example, or release-note prose +# in this repository. All other ai-tells rules remain enforced. +ai-tells.ColonUsage = NO +ai-tells.FigurativeLands = NO +ai-tells.FormalTransitions = NO +ai-tells.OverusedVocabularyVerbs = NO +ai-tells.VerbTricolon = NO diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..ffeb3778b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "ms-python.python" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..69abf060e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "[python]": { + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true + }, + "esbonio.sphinx.confDir": "", + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e52d15ce1..7c995c79a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,20 +1,144 @@ Changelog ========= -.. contents:: +.. towncrier release notes start -Next ----- +2026.08.14 +---------- -2020.09.28.0 +- Drop Python 3.13 support, update VWS Python Mock, and test quota and project-state error responses against the mock. + +- Add ``QuotaExceededError``, ``LicenseCheckFailedError``, and ``AuthorizationFailedError`` for documented VuMark Generation API result codes. + +- Fix ``target_id`` on target exceptions when ``base_vws_url`` includes a path prefix. + +- Retain explicitly provided falsy custom transports instead of replacing them with defaults. + +- Raise a response-carrying ``CloudRecoError`` when Cloud Query returns a documented empty or non-JSON 4xx response instead of leaking ``JSONDecodeError``. + +- Add support for the Model Target Web API. + ``ModelTargetService`` and ``AsyncModelTargetService`` create standard and advanced Model Target datasets, wait for them to be generated, download them and delete them. + +- Map the ``ProjectHasNoApiAccess`` result code, as spelled in Vuforia's result codes table, to ``ProjectHasNoAPIAccessError``. The previously mapped ``ProjectHasNoAPIAccess`` casing, which Vuforia does not document, is no longer mapped. + +- Add support for the Database Reco Counts report. + ``VWS`` and ``AsyncVWS`` take an optional ``database_id``, and have new ``request_database_reco_counts_report``, ``download_reco_counts_report`` and ``wait_for_reco_counts_report`` methods. + +2026.02.25.1 +------------ + + +2026.02.25 +---------- + + +2026.02.24 +---------- + + +2026.02.23 +---------- + + +2026.02.22 +---------- + + +2026.02.21 +---------- + + +2026.02.15 +---------- + + +* Add ``request_timeout_seconds`` parameter to ``VWS`` and ``CloudRecoService``, allowing customization of the request timeout. This accepts a float or a ``(connect, read)`` tuple, matching the ``requests`` library's timeout interface. The default remains 30 seconds. + +2025.03.10.1 +------------ + +2025.03.10 +---------- + +* Removed ``vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName`` which now does not occur in VWS. + +2024.09.21 +------------ + +2024.09.04.1 +------------ + +2024.09.04 +------------ + +* Move ``Response`` from ``vws.exceptions.response`` to ``vws.types``. +* Add ``raw`` field to ``Response``. + +2024.09.03 ------------ -2020.09.25.0 +* Make ``VWS.make_request`` a public method. + +2024.09.02 ------------ -2020.09.08.0 +* Breaking change: Exception names now end with ``Error``. +* Use a timeout (30 seconds) when making requests to the VWS API. +* Type hint changes: images are now ``io.BytesIO`` instances or ``io.BufferedRandom``. + +2024.02.19 ------------ +* Add exception response attribute to ``vws.exceptions.custom_exceptions.RequestEntityTooLarge``. + +2024.02.06 +------------ + +* Exception response attributes are now ``vws.exceptions.response.Response`` instances rather than ``requests.Response`` objects. + +2024.02.04.1 +------------ + +2024.02.04 +------------ + +* Return a new error (``vws.custom_exceptions.ServerError``) when the server returns a 5xx status code. + +2023.12.27 +------------ + +* Breaking change: The ``vws.exceptions.cloud_reco_exceptions.UnknownVWSErrorPossiblyBadName`` is now ``vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName``. +* ``vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName`` now has a ``response`` parameter and attribute. + +2023.12.26 +------------ + +2023.05.21 +------------ + +* Breaking change: the ``vws.exceptions.custom_exceptions.ActiveMatchingTargetsDeleteProcessing`` exception has been removed as Vuforia no longer returns this error. + +2023.03.25 +------------ + +* Support file-like objects in every method which accepts a file. + +2023.03.05 +------------ + +2021.03.28.2 +------------ + +2021.03.28.1 +------------ + +2021.03.28.0 +------------ + +* Breaking change: The ``vws.exceptions.cloud_reco_exceptions.MatchProcessing`` is now ``vws.exceptions.custom_exceptions.ActiveMatchingTargetsDeleteProcessing``. +* Added new exception ``vws.exceptions.custom_exceptions.RequestEntityTooLarge``. +* Add better exception handling when querying a server which does not serve the Vuforia API. + 2020.09.07.0 ------------ diff --git a/LICENSE b/LICENSE index 69f733fdf..c9f18d1a3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,6 @@ -The MIT License +MIT License + +Copyright (c) 2025 Adam Dangoor Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -7,14 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index c3ce12da0..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include src/vws/py.typed -include requirements.txt -include dev-requirements.txt -include setup-requirements.txt -include pyproject.toml diff --git a/Makefile b/Makefile deleted file mode 100644 index 18ce78af3..000000000 --- a/Makefile +++ /dev/null @@ -1,38 +0,0 @@ -SHELL := /bin/bash -euxo pipefail - -include lint.mk - -# Treat Sphinx warnings as errors -SPHINXOPTS := -W - -.PHONY: lint -lint: \ - black \ - check-manifest \ - doc8 \ - flake8 \ - isort \ - linkcheck \ - mypy \ - pip-extra-reqs \ - pip-missing-reqs \ - pyroma \ - shellcheck \ - spelling \ - vulture \ - pylint \ - pydocstyle \ - -.PHONY: fix-lint -fix-lint: \ - autoflake \ - fix-black \ - fix-isort - -.PHONY: docs -docs: - make -C docs clean html SPHINXOPTS=$(SPHINXOPTS) - -.PHONY: open-docs -open-docs: - python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))' diff --git a/README.rst b/README.rst index 6aa88e522..b11993035 100644 --- a/README.rst +++ b/README.rst @@ -1,71 +1,76 @@ -|Build Status| |codecov| |PyPI| |Documentation Status| +|Build Status| |PyPI| vws-python ========== -Python library for the Vuforia Web Services (VWS) API and the Vuforia Web Query API. +Python library for the Vuforia Web Services (VWS) API and the Vuforia +Web Query API. Installation ------------ -.. code:: sh +.. code-block:: shell pip install vws-python -This is tested on Python 3.8+. -Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. +This is tested on Python |minimum-python-version|\+. Get in touch with +``adamdangoor@gmail.com`` if you would like to use this with another +language. Getting Started --------------- -.. code:: python +.. code-block:: python - import io + """Add a target to VWS and then query it.""" + + import os + import pathlib + import uuid from vws import VWS, CloudRecoService - server_access_key = '[server-access-key]' - server_secret_key = '[server-secret-key]' - client_access_key = '[client-access-key]' - client_secret_key = '[client-secret-key]' + server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] + server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] + client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] + client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] vws_client = VWS( server_access_key=server_access_key, server_secret_key=server_secret_key, ) + cloud_reco_client = CloudRecoService( client_access_key=client_access_key, client_secret_key=client_secret_key, ) - name = 'my_image_name' - with open('/path/to/image.png', 'rb') as my_image_file: - my_image = io.BytesIO(my_image_file.read()) + name = "my_image_name_" + uuid.uuid4().hex + + image = pathlib.Path("high_quality_image.jpg") + with image.open(mode="rb") as my_image_file: + target_id = vws_client.add_target( + name=name, + width=1, + image=my_image_file, + active_flag=True, + application_metadata=None, + ) - target_id = vws_client.add_target( - name=name, - width=1, - image=my_image, - active_flag=True, - application_metadata=None, - ) vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=my_image) - assert matching_targets[0].target_id == target_id + with image.open(mode="rb") as my_image_file: + matching_targets = cloud_reco_client.query(image=my_image_file) + assert matching_targets[0].target_id == target_id Full Documentation ------------------ -See the `full documentation `__. +See the `full documentation `__. -.. |Build Status| image:: https://github.com/VWS-Python/vws-python/workflows/CI/badge.svg +.. |Build Status| image:: https://github.com/VWS-Python/vws-python/actions/workflows/ci.yml/badge.svg?branch=main :target: https://github.com/VWS-Python/vws-python/actions -.. |codecov| image:: https://codecov.io/gh/VWS-Python/vws-python/branch/master/graph/badge.svg - :target: https://codecov.io/gh/VWS-Python/vws-python -.. |Documentation Status| image:: https://readthedocs.org/projects/vws-python/badge/?version=latest - :target: https://vws-python.readthedocs.io/en/latest/?badge=latest - :alt: Documentation Status .. |PyPI| image:: https://badge.fury.io/py/VWS-Python.svg :target: https://badge.fury.io/py/VWS-Python +.. |minimum-python-version| replace:: 3.14 diff --git a/admin/__init__.py b/admin/__init__.py deleted file mode 100644 index 6a8f8f73b..000000000 --- a/admin/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Admin tools. -""" diff --git a/admin/release.py b/admin/release.py deleted file mode 100644 index 1c15ac902..000000000 --- a/admin/release.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Release the next version. -""" - -import datetime -import os -import subprocess -from pathlib import Path - -from github import Github -from github.ContentFile import ContentFile -from github.Repository import Repository - - -def get_version(github_repository: Repository) -> str: - """ - Return the next version. - This is today’s date in the format ``YYYY.MM.DD.MICRO``. - ``MICRO`` refers to the number of releases created on this date, - starting from ``0``. - """ - utc_now = datetime.datetime.utcnow() - date_format = '%Y.%m.%d' - date_str = utc_now.strftime(date_format) - tag_labels = [tag.name for tag in github_repository.get_tags()] - today_tag_labels = [ - item for item in tag_labels if item.startswith(date_str) - ] - micro = int(len(today_tag_labels)) - new_version = f'{date_str}.{micro}' - return new_version - - -def update_changelog(version: str, github_repository: Repository) -> None: - """ - Add a version title to the changelog. - """ - changelog_path = Path('CHANGELOG.rst') - branch = 'master' - changelog_content_file = github_repository.get_contents( - path=str(changelog_path), - ref=branch, - ) - # ``get_contents`` can return a ``ContentFile`` or a list of - # ``ContentFile``s. - assert isinstance(changelog_content_file, ContentFile) - changelog_bytes = changelog_content_file.decoded_content - changelog_contents = changelog_bytes.decode('utf-8') - new_changelog_contents = changelog_contents.replace( - 'Next\n----', - f'Next\n----\n\n{version}\n------------', - ) - github_repository.update_file( - path=str(changelog_path), - message=f'Update for release {version}', - content=new_changelog_contents, - sha=changelog_content_file.sha, - ) - - -def build_and_upload_to_pypi() -> None: - """ - Build source and binary distributions. - """ - for args in ( - ['git', 'fetch', '--tags'], - ['git', 'merge', 'origin/master'], - ['rm', '-rf', 'build'], - ['git', 'status'], - ['python', 'setup.py', 'sdist', 'bdist_wheel'], - ['twine', 'upload', '-r', 'pypi', 'dist/*'], - ): - subprocess.run(args=args, check=True) - - -def main() -> None: - """ - Perform a release. - """ - github_token = os.environ['GITHUB_TOKEN'] - github_owner = os.environ['GITHUB_OWNER'] - github_repository_name = os.environ['GITHUB_REPOSITORY_NAME'] - github_client = Github(github_token) - github_repository = github_client.get_repo( - full_name_or_id=f'{github_owner}/{github_repository_name}', - ) - version_str = get_version(github_repository=github_repository) - update_changelog(version=version_str, github_repository=github_repository) - github_repository.create_git_tag_and_release( - tag=version_str, - tag_message='Release ' + version_str, - release_name='Release ' + version_str, - release_message='See CHANGELOG.rst', - type='commit', - object=github_repository.get_commits()[0].sha, - ) - build_and_upload_to_pypi() - - -if __name__ == '__main__': - main() diff --git a/admin/release.sh b/admin/release.sh deleted file mode 100644 index aea94b41a..000000000 --- a/admin/release.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -set -ex - -# Perform a release. -# See the release process documentation for details. -cd "$(mktemp -d)" -git clone git@github.com:"${GITHUB_OWNER}"/"${GITHUB_REPOSITORY_NAME}".git -cd "${GITHUB_REPOSITORY_NAME}" -virtualenv -p python3 release -source release/bin/activate -pip install --editable .[dev] -python admin/release.py diff --git a/codecov.yaml b/codecov.yaml deleted file mode 100644 index e49034f39..000000000 --- a/codecov.yaml +++ /dev/null @@ -1,6 +0,0 @@ -coverage: - status: - patch: - default: - # Require 100% test coverage. - target: 100% diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..e4c927947 --- /dev/null +++ b/conftest.py @@ -0,0 +1,88 @@ +"""Setup for Sybil.""" + +import io # noqa: TC003 +import uuid +from collections.abc import Generator # noqa: TC003 +from doctest import ELLIPSIS +from pathlib import Path + +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase +from sybil import Sybil +from sybil.parsers.rest import ( + ClearNamespaceParser, + DocTestParser, + PythonCodeBlockParser, +) + + +@pytest.fixture(name="make_image_file") +def fixture_make_image_file( + *, + high_quality_image: io.BytesIO, +) -> Generator[None]: + """Make an image file available in the test directory. + + The path of this file matches the path in the documentation. + """ + new_image = Path("high_quality_image.jpg") + buffer = high_quality_image.getvalue() + new_image.write_bytes(data=buffer) + yield + new_image.unlink() + + +@pytest.fixture(name="mock_vws") +def fixture_mock_vws( + *, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Yield a mock VWS. + + The keys used here match the keys in the documentation. + """ + server_access_key = uuid.uuid4().hex + server_secret_key = uuid.uuid4().hex + client_access_key = uuid.uuid4().hex + client_secret_key = uuid.uuid4().hex + database_id = uuid.uuid4().hex + + database = CloudDatabase( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + client_access_key=client_access_key, + client_secret_key=client_secret_key, + database_id=database_id, + ) + + monkeypatch.setenv(name="VWS_SERVER_ACCESS_KEY", value=server_access_key) + monkeypatch.setenv(name="VWS_SERVER_SECRET_KEY", value=server_secret_key) + monkeypatch.setenv(name="VWS_CLIENT_ACCESS_KEY", value=client_access_key) + monkeypatch.setenv(name="VWS_CLIENT_SECRET_KEY", value=client_secret_key) + monkeypatch.setenv(name="VWS_DATABASE_ID", value=database_id) + # The mock accepts one hard-coded pair of Model Target Web API OAuth2 + # credentials, which it does not expose. + monkeypatch.setenv( + name="VWS_MODEL_TARGET_CLIENT_ID", + value="client-id", + ) + monkeypatch.setenv( + name="VWS_MODEL_TARGET_CLIENT_SECRET", + value="client-secret", + ) + # We use a low processing time so that tests run quickly. + with MockVWS(processing_time_seconds=0.2) as mock: + mock.add_cloud_database(cloud_database=database) + yield + + +pytest_collect_file = Sybil( + parsers=[ + ClearNamespaceParser(), + DocTestParser(optionflags=ELLIPSIS), + PythonCodeBlockParser(), + ], + patterns=["*.rst", "*.py"], + fixtures=["make_image_file", "mock_vws"], +).pytest() diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 616fc1243..000000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -# We use dev-requirements.txt instead of just declaring the requirements in -# the setup function because Read The Docs needs a requirements file. -black==20.8b1 -PyYAML==5.3.1 -Pygments==2.7.2 -Sphinx-Substitution-Extensions==2020.9.30.0 -Sphinx==3.2.1 -VWS-Python-Mock==2020.10.3.0 -VWS-Test-Fixtures==2020.9.25.1 -autoflake==1.4 -check-manifest==0.44 -doc8==0.8.1 -dodgy==0.2.1 # Look for uploaded secrets -flake8-commas==2.0.0 # Require silicon valley commas -flake8-quotes==3.2.0 # Require single quotes -flake8==3.8.4 # Lint -freezegun==1.0.0 -isort==5.6.4 # Lint imports -mypy==0.790 # Type checking -pip_check_reqs==2.1.1 -pydocstyle==5.1.1 # Lint docstrings -pyenchant==3.1.1 # Bindings for a spellchecking sytem -pygithub==1.53 -pylint==2.6.0 # Lint -pyroma==2.6 # Packaging best practices checker -pytest-cov==2.10.1 # Measure code coverage -pytest==6.1.1 # Test runners -sphinx-autodoc-typehints==1.11.1 -sphinxcontrib-spelling==7.0.0 -twine==3.2.0 -versioneer==0.18 -vulture==2.1 diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 6225d81e0..000000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = VWSPYTHON -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/__init__.py b/docs/source/__init__.py new file mode 100644 index 000000000..535ceb2ec --- /dev/null +++ b/docs/source/__init__.py @@ -0,0 +1 @@ +"""Documentation.""" diff --git a/docs/source/api-reference.rst b/docs/source/api-reference.rst index 3ffbc13da..f43793ce0 100644 --- a/docs/source/api-reference.rst +++ b/docs/source/api-reference.rst @@ -5,6 +5,30 @@ API Reference :undoc-members: :members: +.. automodule:: vws.async_vws + :undoc-members: + :members: + +.. automodule:: vws.async_query + :undoc-members: + :members: + +.. automodule:: vws.async_vumark_service + :undoc-members: + :members: + +.. automodule:: vws.model_target_service + :undoc-members: + :members: + +.. automodule:: vws.async_model_target_service + :undoc-members: + :members: + +.. automodule:: vws.model_target_datasets + :undoc-members: + :members: + .. automodule:: vws.reports :undoc-members: :members: @@ -12,3 +36,15 @@ API Reference .. automodule:: vws.include_target_data :undoc-members: :members: + +.. automodule:: vws.vumark_accept + :undoc-members: + :members: + +.. automodule:: vws.response + :undoc-members: + :members: + +.. automodule:: vws.transports + :undoc-members: + :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index a44940db2..12553403f 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,119 +1,98 @@ #!/usr/bin/env python3 -""" -Configuration for Sphinx. -""" +"""Configuration for Sphinx.""" -# pylint: disable=invalid-name +import importlib.metadata +from pathlib import Path -import datetime +from packaging.specifiers import SpecifierSet +from sphinx_pyproject import SphinxConfig -from pkg_resources import get_distribution +_pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" +_pyproject_config = SphinxConfig( + pyproject_file=_pyproject_file, + config_overrides={"version": None}, +) -project = 'VWS-Python' -author = 'Adam Dangoor' +project = _pyproject_config.name +author = _pyproject_config.author extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.napoleon', - 'sphinx_autodoc_typehints', - 'sphinx-prompt', - 'sphinx_substitution_extensions', - 'sphinxcontrib.spelling', + "sphinx_copybutton", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx_substitution_extensions", + "sphinxcontrib.spelling", + "sphinxcontrib.towncrier.ext", ] -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' - -year = datetime.datetime.now().year -copyright = f'{year}, {author}' # pylint: disable=redefined-builtin - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# Use ``pkg_resources`` as per -# https://github.com/pypa/setuptools_scm#usage-from-sphinx. -version = get_distribution(project).version -_month, _day, _year, *_ = version.split('.') -release = f'{_month}.{_day}.{_year}' - -language = None +# Render the unreleased ``newsfragments/`` entries into +# ``docs/source/unreleased.rst`` so the Sphinx spelling, doc-build and +# link-checking gates cover the prose before it is assembled into +# CHANGELOG.rst at release time. +towncrier_draft_autoversion_mode = "draft" +towncrier_draft_include_empty = True +towncrier_draft_working_directory = f"{_pyproject_file.parent}" + +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" + +project_copyright = f"%Y, {author}" + +# Exclude the prompt from copied code with sphinx_copybutton. +# https://sphinx-copybutton.readthedocs.io/en/latest/use.html#automatic-exclusion-of-prompts-from-the-copies. +copybutton_exclude = ".linenos, .gp" + +project_metadata = importlib.metadata.metadata(distribution_name=project) +requires_python = project_metadata["Requires-Python"] +specifiers = SpecifierSet(specifiers=requires_python) +(specifier,) = specifiers +if specifier.operator != ">=": + msg = ( + f"We only support '>=' for Requires-Python, got {specifier.operator}." + ) + raise ValueError(msg) +minimum_python_version = specifier.version + +language = "en" # The name of the syntax highlighting style to use. -pygments_style = 'sphinx' -html_theme = 'alabaster' - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# This is required for the alabaster theme -# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - '**': [ - 'relations.html', # needs 'show_related': True theme option to display - 'searchbox.html', - ], -} - -# Output file base name for HTML help builder. -htmlhelp_basename = 'VWSPYTHONdoc' -autoclass_content = 'init' -intersphinx_mapping = { - 'python': ('https://docs.python.org/3.8', None), -} -nitpicky = True -warning_is_error = True -nitpick_ignore = [ - ('py:exc', 'RetryError'), - # See https://bugs.python.org/issue31024 for why Sphinx cannot find this. - ('py:class', 'typing.Tuple'), - ('py:class', 'typing.Optional'), - ('py:class', '_io.BytesIO'), - ('py:class', 'docker.types.services.Mount'), - # Requests documentation exposes ``requests.Response``, not - # ``requests.models.response``. - ('py:class', 'requests.models.Response'), - ('py:class', 'requests.exceptions.ConnectionError'), -] +pygments_style = "sphinx" +html_theme = "furo" +html_title = project html_show_copyright = False html_show_sphinx = False html_show_sourcelink = False -autoclass_content = 'both' - html_theme_options = { - 'show_powered_by': 'false', + "sidebar_hide_name": False, + "source_repository": "https://github.com/VWS-Python/vws-python/", + "source_branch": "main", + "source_directory": "docs/source/", } -html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', - 'searchbox.html', - ], +# Output file base name for HTML help builder. +htmlhelp_basename = "VWSPYTHONdoc" +intersphinx_mapping = { + "python": (f"https://docs.python.org/{minimum_python_version}", None), } +nitpicky = True +nitpick_ignore = (("py:class", "_io.BytesIO"),) +warning_is_error = True + +autoclass_content = "both" -# Don't check anchors because many websites use #! for AJAX magic -# http://sphinx-doc.org/config.html#confval-linkcheck_anchors -linkcheck_anchors = False # Retry link checking to avoid transient network errors. linkcheck_retries = 5 -linkcheck_ignore = [ - # Requires login. - r'https://developer.vuforia.com/targetmanager', -] -spelling_word_list_filename = '../../spelling_private_dict.txt' +spelling_word_list_filename = "../../spelling_private_dict.txt" -autodoc_member_order = 'bysource' +autodoc_member_order = "bysource" rst_prolog = f""" .. |project| replace:: {project} -.. |release| replace:: {release} +.. |minimum-python-version| replace:: {minimum_python_version} .. |github-owner| replace:: VWS-Python .. |github-repository| replace:: vws-python """ - -always_document_param_types = True diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 5f05dac58..55d902399 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -1,8 +1,6 @@ Contributing to |project| ========================= -.. contents:: - Contributions to this repository must pass tests and linting. CI is the canonical source of truth. @@ -12,46 +10,50 @@ Install contribution dependencies Install Python dependencies in a virtual environment. -.. prompt:: bash +.. code-block:: console - pip install --editable '.[dev]' + $ pip install --editable '.[dev]' Spell checking requires ``enchant``. -This can be installed on macOS, for example, with `Homebrew `__: +This can be installed on macOS, for example, with `Homebrew`_: -.. prompt:: bash +.. code-block:: console - brew install enchant + $ brew install enchant and on Ubuntu with ``apt``: -.. prompt:: bash +.. code-block:: console - apt-get install -y enchant + $ apt-get install -y enchant -Linting -------- +Install ``prek`` hooks: -Run lint tools: +.. code-block:: console -.. prompt:: bash + $ prek install + +Linting +------- - make lint +Run lint tools either by committing, or with: -To fix some lint errors, run the following: +.. code-block:: console -.. prompt:: bash + $ prek run --all-files --hook-stage pre-commit --verbose + $ prek run --all-files --hook-stage pre-push --verbose + $ prek run --all-files --hook-stage manual --verbose - make fix-lint +.. _Homebrew: https://brew.sh Running tests ------------- Run ``pytest``: -.. prompt:: bash +.. code-block:: console - pytest + $ pytest Documentation ------------- @@ -60,10 +62,10 @@ Documentation is built on Read the Docs. Run the following commands to build and view documentation locally: -.. prompt:: bash +.. code-block:: console - make docs - make open-docs + $ uv run --extra=dev sphinx-build -M html docs/source docs/build -W + $ python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))' Continuous integration ---------------------- diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index b93f5b1e6..baea7b495 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -1,8 +1,6 @@ Exceptions ========== -.. contents:: - Base exceptions --------------- @@ -30,6 +28,15 @@ CloudRecoService exceptions :inherited-members: Exception :exclude-members: errno, filename, filename2, strerror +ModelTargetService exceptions +----------------------------- + +.. automodule:: vws.exceptions.model_target_exceptions + :members: + :show-inheritance: + :inherited-members: Exception + :exclude-members: errno, filename, filename2, strerror + Custom exceptions ----------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 9ac46da27..c8b5bdaf6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,11 +4,11 @@ Installation ------------ -.. prompt:: bash +.. code-block:: console - pip3 install vws-python + $ pip install vws-python -This is tested on Python 3.8+. +This is tested on Python |minimum-python-version|\+. Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. Usage @@ -16,84 +16,230 @@ Usage See the :doc:`api-reference` for full usage details. -.. code:: python +.. code-block:: python - import io + """Add a target to VWS and then query it.""" + + import os + import pathlib + import uuid from vws import VWS, CloudRecoService - server_access_key = '[server-access-key]' - server_secret_key = '[server-secret-key]' - client_access_key = '[client-access-key]' - client_secret_key = '[client-secret-key]' + server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] + server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] + client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] + client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] vws_client = VWS( server_access_key=server_access_key, server_secret_key=server_secret_key, ) + cloud_reco_client = CloudRecoService( client_access_key=client_access_key, client_secret_key=client_secret_key, ) - name = 'my_image_name' - with open('/path/to/image.png', 'rb') as my_image_file: - my_image = io.BytesIO(my_image_file.read()) + name = "my_image_name_" + uuid.uuid4().hex + + image = pathlib.Path("high_quality_image.jpg") + with image.open(mode="rb") as my_image_file: + target_id = vws_client.add_target( + name=name, + width=1, + image=my_image_file, + active_flag=True, + application_metadata=None, + ) - target_id = vws_client.add_target( - name=name, - width=1, - image=my_image, - active_flag=True, - application_metadata=None, - ) vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=my_image) + + with image.open(mode="rb") as my_image_file: + matching_targets = cloud_reco_client.query(image=my_image_file) assert matching_targets[0].target_id == target_id +Recognition counts +------------------ + +Vuforia can generate a report of the number of recognitions of each target in a database in a month. +Only the current month and the previous month can be requested. + +This needs the ID of the database, which is shown in the Vuforia target manager. + +The report is generated in the background, and the URL it is served from expires just under seven days after it is requested. + +.. clear-namespace + +.. code-block:: python + + """Get the number of recognitions of each target this month.""" + + import calendar + import datetime + import os + + from vws import VWS + + server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] + server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] + database_id = os.environ["VWS_DATABASE_ID"] + + vws_client = VWS( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + database_id=database_id, + ) + + now = datetime.datetime.now(tz=datetime.UTC) + + report_request = vws_client.request_database_reco_counts_report( + year=now.year, + month=calendar.Month(value=now.month), + ) + + report = vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + reco_counts_by_target_id = { + item.target_id: item.reco_count for item in report.reco_counts + } + + # This database has no targets, so nothing has been recognized. + assert not reco_counts_by_target_id + +Model Targets +------------- + +Vuforia generates Model Target datasets from CAD models. +This uses OAuth2 client credentials, which are separate from the VWS server keys. + +Dataset generation happens in the background, and the generated dataset is downloaded as a zip file. + +.. clear-namespace + +.. code-block:: python + + """Generate a Model Target dataset and download it.""" + + import os + + from vws import ModelTargetService + from vws.model_target_datasets import ( + CadDataFormat, + GuideViewPosition, + ModelTargetDatasetType, + ModelTargetModel, + ModelTargetView, + ) + from vws.reports import ModelTargetDatasetStatuses + + client_id = os.environ["VWS_MODEL_TARGET_CLIENT_ID"] + client_secret = os.environ["VWS_MODEL_TARGET_CLIENT_SECRET"] + + model_target_client = ModelTargetService( + client_id=client_id, + client_secret=client_secret, + ) + + model = ModelTargetModel( + name="my_model", + cad_data_url="https://example.com/my_model.zip", + cad_data_format=CadDataFormat.ZIP, + views=[ + ModelTargetView( + name="front", + guide_view_position=GuideViewPosition( + rotation=[0.0, 0.0, 0.0, 1.0], + translation=[0.0, 0.0, 1.0], + ), + ), + ], + ) + + dataset_uuid = model_target_client.create_dataset( + name="my_dataset", + target_sdk="11.0", + models=[model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + + dataset = model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + # The dataset is a zip file. + assert dataset.startswith(b"PK") + + model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + Testing ------- To write unit tests for code which uses this library, without using your Vuforia quota, you can use the `VWS Python Mock`_ tool: -.. prompt:: bash +.. code-block:: console - pip3 install vws-python-mock + $ pip install vws-python-mock -.. code:: python +.. clear-namespace - from mock_vws import MockVWS, VuforiaDatabase +.. code-block:: python - with MockVWS() as mock: - database = VuforiaDatabase() - mock.add_database(database=database) - vws_client = VWS( - server_access_key=server_access_key, - server_secret_key=server_secret_key, - ) - cloud_reco_client = CloudRecoService( - client_access_key=client_access_key, - client_secret_key=client_secret_key, - ) + """Add a target to VWS and then query it.""" - name = 'my_image_name' + import pathlib - with open('/path/to/image.png', 'rb') as my_image_file: - my_image = io.BytesIO(my_image_file.read()) + from mock_vws import MockVWS + from mock_vws.database import CloudDatabase - target_id = vws_client.add_target( - name=name, - width=1, - image=my_image, - ) + from vws import VWS, CloudRecoService + + with MockVWS() as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + image = pathlib.Path("high_quality_image.jpg") + with image.open(mode="rb") as my_image_file: + target_id = vws_client.add_target( + name="example_image_name", + width=1, + image=my_image_file, + application_metadata=None, + active_flag=True, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + matching_targets = cloud_reco_client.query(image=my_image_file) + + assert matching_targets[0].target_id == target_id There are some differences between the mock and the real Vuforia. -See https://vws-python-mock.readthedocs.io/en/latest/differences-to-vws.html for details. +See https://vws-python.github.io/vws-python-mock/differences-to-vws for details. .. _VWS Python Mock: https://github.com/VWS-Python/vws-python-mock - Reference --------- @@ -104,4 +250,5 @@ Reference exceptions contributing release-process + unreleased changelog diff --git a/docs/source/release-process.rst b/docs/source/release-process.rst index 9459b62e5..db1744feb 100644 --- a/docs/source/release-process.rst +++ b/docs/source/release-process.rst @@ -7,67 +7,16 @@ Outcomes * A new ``git`` tag available to install. * A new package on PyPI. -Prerequisites -~~~~~~~~~~~~~ - -* ``python3`` on your ``PATH`` set to Python 3.8+. -* ``virtualenv``. -* Push access to this repository. -* Trust that ``master`` is ready and high enough quality for release. - Perform a Release ~~~~~~~~~~~~~~~~~ -#. Install keyring - - Make sure that `keyring `__ is available on your path. - - E.g.: - - .. prompt:: bash - - python3 -m pip install --user pipx - python3 -m pipx ensurepath - pipx install keyring - -#. Set up PyPI credentials - -Register at `PyPI `__. - -Add the following information to :file:`~/.pypirc`. - -.. code:: ini - - [distutils] - index-servers= - pypi - - [pypi] - username = - -Store your PyPI password: - -.. prompt:: bash - - keyring set https://upload.pypi.org/legacy/ - -#. Get a GitHub access token: - - Follow the `GitHub access token instructions`_ for getting an access token. - -#. Set environment variables to GitHub credentials, e.g.: - - .. prompt:: bash - - export GITHUB_TOKEN=75c72ad718d9c346c13d30ce762f121647b502414 +#. `Install GitHub CLI`_. #. Perform a release: - .. prompt:: bash + .. code-block:: console :substitutions: - export GITHUB_OWNER=|github-owner| - export GITHUB_REPOSITORY_NAME=|github-repository| - curl https://raw.githubusercontent.com/"$GITHUB_OWNER"/"$GITHUB_REPOSITORY_NAME"/master/admin/release.sh | bash + $ gh workflow run release.yml --repo "|github-owner|/|github-repository|" -.. _GitHub access token instructions: https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line/ +.. _Install GitHub CLI: https://cli.github.com/ diff --git a/docs/source/unreleased.rst b/docs/source/unreleased.rst new file mode 100644 index 000000000..22ac74723 --- /dev/null +++ b/docs/source/unreleased.rst @@ -0,0 +1,8 @@ +Unreleased changes +================== + +Changes that have landed on the main branch but are not yet part of a +tagged release. These entries are assembled into the +:doc:`changelog` when the next release is published. + +.. towncrier-draft-entries:: diff --git a/docs/towncrier_template.rst.jinja b/docs/towncrier_template.rst.jinja new file mode 100644 index 000000000..6da878330 --- /dev/null +++ b/docs/towncrier_template.rst.jinja @@ -0,0 +1,14 @@ + +{% for section_name, section in sections.items() %} +{% if section %} +{% for category, entries in section.items() %} +{% for text, _ in entries.items() %} +- {{ text }} + +{% endfor %} +{% endfor %} +{% else %} +No significant changes. + +{% endif %} +{% endfor %} diff --git a/lint.mk b/lint.mk deleted file mode 100644 index 810325e21..000000000 --- a/lint.mk +++ /dev/null @@ -1,82 +0,0 @@ -# Make commands for linting - -SHELL := /bin/bash -euxo pipefail - -.PHONY: black -black: - black --check . - -.PHONY: fix-black -fix-black: - black . - -.PHONY: mypy -mypy: - mypy *.py src/ tests/ docs/source/ admin - -.PHONY: check-manifest -check-manifest: - check-manifest . - -.PHONY: doc8 -doc8: - doc8 . - -.PHONY: flake8 -flake8: - flake8 . - -.PHONY: isort -isort: - isort --check-only . - -.PHONY: fix-isort -fix-isort: - isort . - -.PHONY: pip-extra-reqs -pip-extra-reqs: - pip-extra-reqs src/ - -.PHONY: pip-missing-reqs -pip-missing-reqs: - pip-missing-reqs src/ - -.PHONY: pylint -pylint: - pylint *.py src/ tests/ admin/ docs/ - -.PHONY: pyroma -pyroma: - pyroma --min 10 . - -.PHONY: vulture -vulture: - vulture --min-confidence 100 --exclude _vendor --exclude .eggs . - -.PHONY: linkcheck -linkcheck: - $(MAKE) -C docs/ linkcheck SPHINXOPTS=$(SPHINXOPTS) - -.PHONY: spelling -spelling: - $(MAKE) -C docs/ spelling SPHINXOPTS=$(SPHINXOPTS) - -.PHONY: shellcheck -shellcheck: - shellcheck --exclude SC2164,SC1091 */*.sh - -.PHONY: autoflake -autoflake: - autoflake \ - --in-place \ - --recursive \ - --remove-all-unused-imports \ - --remove-unused-variables \ - --expand-star-imports \ - --exclude _vendor,release \ - . - -.PHONY: pydocstyle -pydocstyle: - pydocstyle diff --git a/newsfragments/.gitkeep b/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pyproject.toml b/pyproject.toml index c475e168a..008b5c631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,145 +1,470 @@ -[tool.pylint] - - [tool.pylint.'MASTER'] - - # Pickle collected data for later comparisons. - persistent = true - - # Use multiple processes to speed up Pylint. - jobs = 0 - - # List of plugins (as comma separated values of python modules names) to load, - # usually to register additional checkers. - load-plugins = ['pylint.extensions.docparams'] - - # Allow loading of arbitrary C extensions. Extensions are imported into the - # active Python interpreter and may run arbitrary code. - unsafe-load-any-extension = false - - [tool.pylint.'MESSAGES CONTROL'] - - # Enable the message, report, category or checker with the given id(s). You can - # either give multiple identifier separated by comma (,) or put this option - # multiple time (only on the command line, not in the configuration file where - # it should appear only once). See also the "--disable" option for examples. - enable = [ - 'spelling', - 'useless-suppression', - ] - - # Disable the message, report, category or checker with the given id(s). You - # can either give multiple identifiers separated by comma (,) or put this - # option multiple times (only on the command line, not in the configuration - # file where it should appear only once).You can also use "--disable=all" to - # disable everything first and then reenable specific checks. For example, if - # you want to run only the similarities checker, you can use "--disable=all - # --enable=similarities". If you want to run only the classes checker, but have - # no Warning level messages displayed, use"--disable=all --enable=classes - # --disable=W" - - disable = [ - # Tests need `self` to be in a class but do not use it. - 'no-self-use', - 'too-few-public-methods', - 'too-many-locals', - 'too-many-arguments', - 'too-many-instance-attributes', - 'too-many-return-statements', - 'too-many-lines', - 'locally-disabled', - # Let flake8 handle long lines - 'line-too-long', - # Let flake8 handle unused imports - 'unused-import', - # Let isort deal with sorting - 'ungrouped-imports', - # We don't need everything to be documented because of mypy - 'missing-type-doc', - 'missing-returns-doc', - 'missing-return-type-doc', - # Let auto-formatters deal with this - 'bad-continuation', - # Let auto-formatters deal with this - 'bad-whitespace', - # Too difficult to please - 'duplicate-code', - # Let isort handle imports - 'wrong-import-order', - ] - - [tool.pylint.'FORMAT'] - - # Allow the body of an if to be on the same line as the test if there is no - # else. - single-line-if-stmt = false - - [tool.pylint.'SPELLING'] - - # Spelling dictionary name. Available dictionaries: none. To make it working - # install python-enchant package. - spelling-dict = 'en_US' - - # A path to a file that contains private dictionary; one word per line. - spelling-private-dict-file = 'spelling_private_dict.txt' - - # Tells whether to store unknown words to indicated private dictionary in - # --spelling-private-dict-file option instead of raising a message. - spelling-store-unknown-words = 'no' - -[tool.black] +[build-system] +build-backend = "setuptools.build_meta" +requires = [ + "setuptools", + "setuptools-scm>=8.1.0", +] -line-length = 79 -skip-string-normalization = true +[project] +name = "vws-python" +description = "Interact with the Vuforia Web Services (VWS) API." +readme = { file = "README.rst", content-type = "text/x-rst" } +keywords = [ + "client", + "vuforia", + "vws", +] +license = "MIT" +authors = [ + { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, +] +requires-python = ">=3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.14", +] +dynamic = [ + "version", +] +dependencies = [ + "beartype>=0.22.9", + "httpx>=0.28.0", + "requests>=2.32.3", + "urllib3>=2.2.3", + "vws-auth-tools>=2024.7.12", +] +optional-dependencies.dev = [ + "actionlint-py==1.7.12.24", + "check-manifest==0.51", + "deptry==0.25.1", + "doc8==2.0.0", + "doccmd==2026.8.16", + "freezegun==1.5.5", + "furo==2025.12.19", + "interrogate==1.7.0", + "mypy[faster-cache]==2.3.0", + "mypy-strict-kwargs==2026.7.19.1", + "no-defaults==2.1.0", + "prek==0.4.13", + "pydocstringformatter==1.0.0", + "pydocstyle==6.3", + "pygments==2.20.0", + "pylint[spelling]==4.0.7", + "pylint-per-file-ignores==3.2.1", + "pyproject-fmt==2.27.0", + "pyrefly==1.2.0", + "pyright==1.1.411", + "pyroma==5.0.1", + "pytest==9.1.1", + "pytest-asyncio==1.4.0", + "pytest-beartype-tests==2026.8.16", + "pytest-cov==7.1.0", + "pyyaml==6.0.3", + "ruff==0.16.2", + # We add shellcheck-py not only for shell scripts and shell code blocks, + # but also because having it installed means that ``actionlint-py`` will + # use it to lint shell commands in GitHub workflow files. + "shellcheck-py==0.11.0.1", + "shfmt-py==4.0.0", + "sphinx==9.1.0", + "sphinx-copybutton==0.5.2", + "sphinx-lint==1.0.2", + "sphinx-pyproject==0.3.0", + "sphinx-substitution-extensions==2026.8.5", + "sphinxcontrib-spelling==8.0.2", + # ``sphinxcontrib-towncrier`` renders unreleased news fragments + # into docs/source/unreleased.rst during Sphinx builds. + "sphinxcontrib-towncrier==0.5.0a0", + "strict-kwargs==2026.7.24", + "sybil==10.1.0", + "towncrier==25.8.0", + "ty==0.0.70", + "types-requests==2.33.0.20260712", + "vale==3.13.0.0", + "vulture==2.16", + "vws-python-mock==2026.8.14", + "vws-test-fixtures==2026.8.16", + "yamlfix==1.19.1", + "zizmor==1.29.0", +] +optional-dependencies.release = [ "check-wheel-contents==0.6.3", "towncrier==25.8.0" ] +urls.Documentation = "https://vws-python.github.io/vws-python/" +urls.Source = "https://github.com/VWS-Python/vws-python" -[tool.isort] +[dependency-groups] +dev = [] -multi_line_output = 3 -include_trailing_comma = true +[tool.setuptools] +packages.find.where = [ + "src", +] +package-data.vws = [ + "py.typed", +] +zip-safe = false -[tool.coverage.run] +[tool.setuptools_scm] +# This keeps the start of the version the same as the last release. +# This is useful for our documentation to include e.g. binary links +# to the latest released binary. +# +# Code to match this is in ``conf.py``. +version_scheme = "post-release" -branch = true +[tool.ruff] +line-length = 79 +lint.select = [ + "ALL", +] +lint.ignore = [ + # Ruff warns that this conflicts with the formatter. + "COM812", + # This project does not use per-file copyright notices. + "CPY001", + # Allow our chosen docstring line-style - pydocstringformatter handles formatting + # but doesn't enforce D205 (blank line after summary) or D212 (summary on first line). + "D205", + "D212", + # Ruff warns that this conflicts with the formatter. + "ISC001", + # Ignore 'too-many-*' errors as they seem to get in the way more than + # helping. + "PLR0913", +] +lint.per-file-ignores."doccmd_*.py" = [ + # Allow asserts in docs. + "S101", +] +lint.per-file-ignores."docs/source/*.py" = [ + # Allow asserts in docs. + "S101", +] +lint.per-file-ignores."tests/*.py" = [ + # Allow asserts in tests. + "S101", +] +# Do not automatically remove commented out code. +# We comment out code during development, and with VSCode auto-save, this code +# is sometimes annoyingly removed. +lint.unfixable = [ + "ERA001", +] +lint.external = [ "NOD" ] +lint.flake8-tidy-imports.banned-api."typing.cast".msg = "typing.cast is banned: use explicit type narrowing or a typed variable instead." +lint.pydocstyle.convention = "google" -[tool.pytest.ini_options] +[tool.pylint] +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +"MESSAGES CONTROL".disable = [ + # Too difficult to please + "duplicate-code", + # Let ruff handle long lines + "line-too-long", + "locally-disabled", + "missing-return-type-doc", + # We don't need everything to be documented because of mypy + "missing-type-doc", + "too-few-public-methods", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-return-statements", + # Let ruff deal with sorting + "ungrouped-imports", + # Let ruff handle unused imports + "unused-import", + # mypy does not want untyped parameters. + "useless-type-doc", + # Let ruff handle imports + "wrong-import-order", +] +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +"MESSAGES CONTROL".enable = [ + "bad-inline-option", + "deprecated-pragma", + "file-ignored", + "spelling", + "use-symbolic-message-instead", + "useless-suppression", +] +# 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. +# - conf.py is a Sphinx configuration file which requires lowercase global variable names. +"MESSAGES CONTROL".per-file-ignores = [ + "docs/source/conf.py:invalid-name", + "docs/source/doccmd_*.py:invalid-name", + "docs/source/doccmd_*/*.py:invalid-name", + "doccmd_README_rst_*.py:invalid-name", + "doccmd_*/*.py:invalid-name", +] +DEPRECATED_BUILTINS.bad-functions = [ + # Use Pylint until Ruff can ban bare builtin calls, or until custom rules + # make this removable: + # https://github.com/astral-sh/ruff/issues/10079 + # https://github.com/astral-sh/ruff/issues/970 + "filter", + "getattr", + "hasattr", + "map", + "setattr", +] +# Allow the body of an if to be on the same line as the test if there is no +# else. +FORMAT.single-line-if-stmt = false +# Return non-zero exit code if useless-suppression is emitted. +MAIN.fail-on = [ + "useless-suppression", +] +# Use multiple processes to speed up Pylint. +MASTER.jobs = 0 +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +# See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. +# and we also add `pylint_per_file_ignores` to allow per-file ignores. +# We do not use the plugins: +# - pylint.extensions.code_style +# - pylint.extensions.magic_value +# - pylint.extensions.while_used +# as they seemed to get in the way. +MASTER.load-plugins = [ + "pylint.extensions.bad_builtin", + "pylint.extensions.comparison_placement", + "pylint.extensions.consider_refactoring_into_while_condition", + "pylint.extensions.docparams", + "pylint.extensions.dunder", + "pylint.extensions.eq_without_hash", + "pylint.extensions.for_any_all", + "pylint.extensions.mccabe", + "pylint.extensions.no_self_use", + "pylint.extensions.overlapping_exceptions", + "pylint.extensions.private_import", + "pylint.extensions.redefined_loop_name", + "pylint.extensions.redefined_variable_type", + "pylint.extensions.set_membership", + "pylint.extensions.typing", + "pylint_per_file_ignores", +] +# Pickle collected data for later comparisons. +MASTER.persistent = true +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +MASTER.unsafe-load-any-extension = false +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +SPELLING.spelling-dict = "en_US" +# A path to a file that contains private dictionary; one word per line. +SPELLING.spelling-private-dict-file = "spelling_private_dict.txt" +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +SPELLING.spelling-store-unknown-words = "no" -xfail_strict = true -log_cli = true +[tool.interrogate] +fail-under = 100 +verbose = 2 +omit-covered-files = true [tool.check-manifest] - ignore = [ "*.enc", - ".appveyor.yml", - ".coveragerc", - ".isort.cfg", - ".markdownlint.json", - ".pydocstyle", - ".remarkrc", - ".readthedocs.yml", - "readthedocs.yaml", - ".style.yapf", - ".travis.yml", - "admin", - "admin/**", + ".checkmake-config.ini", + ".git_archival.txt", + ".pre-commit-config.yaml", + ".prettierrc", + ".vale.ini", + ".yamlfmt", "CHANGELOG.rst", - "CODE_OF_CONDUCT.rst", - "CONTRIBUTING.rst", - "LICENSE", - "Makefile", "ci", "ci/**", - "codecov.yaml", + "CODE_OF_CONDUCT.rst", + "CONTRIBUTING.rst", "doc8.ini", "docs", "docs/**", - ".git_archival.txt", - "mypy.ini", - "pylintrc", - "pytest.ini", + "LICENSE", + "lint.mk", + "Makefile", + "newsfragments", + "newsfragments/**", "spelling_private_dict.txt", "tests", "tests-pylintrc", "tests/**", "vuforia_secrets.env.example", - "lint.mk", + "zizmor.yml", +] + +[tool.deptry] +optional_dependencies_dev_groups = [ + "dev", + "release", ] + +[tool.vulture] +# Duplicate some of .gitignore +exclude = [ ".venv" ] +# Ideally we would limit the paths to the source code where we want to ignore names, +# but Vulture does not enable this. +ignore_names = [ + # Model Target model option values which this library does not use + # itself, from vws.model_target_datasets + "ADAPTIVE", + "ALWAYS", + "AR_CONTROLLER", + # Public API classes imported by users from vws.transports + "AsyncHTTPXTransport", + "AUTO", + # Sphinx + "autoclass_content", + "autoclass_content", + "autodoc_member_order", + "CAR", + "copybutton_exclude", + "DAE", + "DEFAULT", + "DYNAMIC", + "extensions", + "FALSE", + "FBX", + # pytest fixtures - we name fixtures like this for this purpose + "fixture_*", + "html_show_copyright", + "html_show_sourcelink", + "html_show_sphinx", + "html_theme", + "html_theme_options", + "html_title", + "htmlhelp_basename", + "HTTPXTransport", + "IGES", + "intersphinx_mapping", + "language", + "linkcheck_ignore", + "linkcheck_retries", + "LOW_FEATURE_OBJECTS", + "master_doc", + "NEVER", + "nitpick_ignore", + "nitpicky", + "OBJ", + "project_copyright", + "PVZ", + "pygments_style", + # pytest configuration + "pytest_collect_file", + "pytest_plugins", + "rst_prolog", + "SCAN", + "source_suffix", + "spelling_word_list_filename", + "STATIC", + "STL", + "templates_path", + "towncrier_draft_autoversion_mode", + "towncrier_draft_include_empty", + "towncrier_draft_working_directory", + "VRML", + "warning_is_error", +] + +[tool.pyproject-fmt] +indent = 4 +keep_full_version = true +max_supported_python = "3.14" + +[tool.mypy] +files = [ "." ] +exclude = [ "build" ] +exclude_gitignore = true +follow_untyped_imports = true +strict = true +plugins = [ + "mypy_strict_kwargs", +] + +[tool.pyrefly] +errors.non-exhaustive-match = "error" + +[tool.pyright] +typeCheckingMode = "strict" +enableTypeIgnoreComments = false +reportUnnecessaryTypeIgnoreComment = true + +[tool.pytest] +log_cli = true +xfail_strict = true + +[tool.coverage] +run.branch = true +report.exclude_also = [ + "if TYPE_CHECKING:", +] +report.show_missing = true + +[tool.towncrier] +# The changelog and the per-release GitHub release notes are both built +# from news fragments under ``newsfragments/``. The release workflow +# runs ``towncrier build`` to assemble them; contributors add one +# fragment file per user-facing change. +directory = "newsfragments" +filename = "CHANGELOG.rst" +# Custom template so an assembled version reproduces the historical +# style exactly: a bare ```` heading (no project name, no +# date) followed by a flat bullet list with no per-type sub-headings. +template = "docs/towncrier_template.rst.jinja" +title_format = "{version}" +issue_format = "#{issue}" +# ``title_format`` underline first, then any nested headings. A bare +# version such as ``2026.05.18`` underlined with ``-`` matches every +# pre-towncrier entry in CHANGELOG.rst. +underlines = [ "-", "~", "^" ] +type = [ + # A single, unnamed fragment type keeps the assembled output as one + # flat bullet list, matching the historical changelog (which never + # grouped entries under "Features"/"Bugfixes"/... sub-headings). + { directory = "change", name = "", showcontent = true }, +] + +[tool.pydocstringformatter] +write = true +split-summary-body = false +# Use a lower line length than ruff (79) to avoid conflicts with D200 - +# pydocstringformatter would otherwise split docstrings at exactly 79 chars +# which ruff considers should stay on one line. +max-line-length = 75 +linewrap-full-docstring = true + +[tool.doc8] +max_line_length = 2000 +ignore_path = [ + "./.eggs", + "./docs/build", + "./docs/build/spelling/output.txt", + "./node_modules", + "./src/*.egg-info/", + "./src/*/_setuptools_scm_version.txt", +] + +[tool.yamlfix] +section_whitelines = 1 +whitelines = 1 + +[tool.no_defaults] +private_only = true +per_file_enforcement."tests/**" = "all" diff --git a/readthedocs.yaml b/readthedocs.yaml deleted file mode 100644 index dd0105691..000000000 --- a/readthedocs.yaml +++ /dev/null @@ -1,18 +0,0 @@ -version: 2 - -# We do this because at the time of writing we need "image: latest" for Python -# 3.8. -build: - image: latest - -python: - install: - - method: pip - path: . - extra_requirements: - - dev - version: 3.8 - -sphinx: - builder: html - fail_on_warning: true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2ba459c37..000000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -VWS-Auth-Tools -func-timeout -requests -urllib3 diff --git a/setup-requirements.txt b/setup-requirements.txt deleted file mode 100644 index 78f0bb68e..000000000 --- a/setup-requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -setuptools_scm==4.1.2 -setuptools-scm-git-archive==1.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 4c8187645..000000000 --- a/setup.cfg +++ /dev/null @@ -1,85 +0,0 @@ -[flake8] -exclude=./.eggs, - ./build/, - -[bdist_wheel] -universal = 1 - -[doc8] -max-line-length = 2000 -ignore-path = ./src/*.egg-info/SOURCES.txt,./docs/build/spelling/output.txt,./.eggs,src/*/_setuptools_scm_version.txt - -[mypy] -check_untyped_defs = True -disallow_incomplete_defs = True -disallow_subclassing_any = True -disallow_untyped_calls = True -disallow_untyped_decorators = False -disallow_untyped_defs = True -follow_imports = silent -ignore_missing_imports = True -no_implicit_optional = True -strict_equality = True -strict_optional = True -warn_no_return = True -warn_redundant_casts = True -warn_return_any = True -warn_unused_configs = True -warn_unused_ignores = True - -[pydocstyle] -# No summary lines -# - D200 -# - D205 -# - D400 -# - D415 -# We don't want blank lines before class docstrings -# - D203 -# We don't need docstrings to start at the first line -# - D212 -# Allow blank lines after function docstrings -# - D202 -# Section names do not need to end in newlines -# - D406 -# Section names do not need dashed underlines -# - D407 -# No blank line is needed after the last section -# Do not care about imperative mood -# - D401 -ignore = D200,D202,D203,D205,D212,D400,D406,D407,D413,D401,D415 - -[metadata] -name = VWS Python -description = Interact with the Vuforia Web Services (VWS) API. -long_description = file: README.rst -long_description_content_type = text/x-rst -keywords = vuforia client -license = MIT License -license_file = LICENSE -classifiers = - Operating System :: POSIX - Environment :: Web Environment - Programming Language :: Python :: 3.8 - License :: OSI Approved :: MIT License - Development Status :: 5 - Production/Stable -url = https://vws-python.readthedocs.io -author = Adam Dangoor -author_email = adamdangoor@gmail.com - -[options] -zip_safe = False -include_package_data = True -# Avoid dependency links because they are not supported by Read The Docs. -# -# Also, they require users to use ``--process-dependency-links``. -dependency_links = -package_dir= - =src -packages=find: - -[options.packages.find] -where=src - -[options.package_data] -vws = - py.typed diff --git a/setup.py b/setup.py deleted file mode 100644 index f3c54acc7..000000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Setup script for VWS Python, a wrapper for Vuforia's Web Services APIs.""" - -from pathlib import Path -from typing import List - -from setuptools import setup - - -def _get_dependencies(requirements_file: Path) -> List[str]: - """ - Return requirements from a requirements file. - - This expects a requirements file with no ``--find-links`` lines. - """ - lines = requirements_file.read_text().strip().split('\n') - return [line for line in lines if not line.startswith('#')] - - -INSTALL_REQUIRES = _get_dependencies( - requirements_file=Path('requirements.txt'), -) - -DEV_REQUIRES = _get_dependencies( - requirements_file=Path('dev-requirements.txt'), -) - -SETUP_REQUIRES = _get_dependencies( - requirements_file=Path('setup-requirements.txt'), -) - -setup( - use_scm_version=True, - setup_requires=SETUP_REQUIRES, - install_requires=INSTALL_REQUIRES, - extras_require={'dev': DEV_REQUIRES}, - python_requires='>=3', -) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 337aff786..09386f1d4 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,15 +1,23 @@ AuthenticationFailure +AuthorizationFailed BadImage ConnectionErrorPossiblyImageTooLarge DateRangeError +Falsy ImageTooLarge InactiveProject +JSONDecodeError +LicenseCheckFailed MatchProcessing MaxNumResultsOutOfRange MetadataTooLarge -ProjectHasNoAPIAccess +OAuth +OopsAnErrorOccurredPossiblyBadName +OopsAnErrorOccurredPossiblyBadNameError +ProjectHasNoApiAccess ProjectInactive ProjectSuspended +QuotaExceeded RequestQuotaReached RequestTimeTooSkewed TargetNameExist @@ -17,13 +25,17 @@ TargetProcessingTimeout TargetQuotaReached TargetStatusNotSuccess TargetStatusProcessing +TooManyRequests Ubuntu UnknownTarget -UnknownVWSErrorPossiblyBadName admin api args ascii +async +asyncio +balancer +beartype bool boolean bytesio @@ -31,12 +43,16 @@ changelog chunked cmyk connectionerror +csv +customizable dataclasses datetime decodable dev dict docstring +enum +falsy filename foo formdata @@ -48,6 +64,7 @@ hmac html http https +httpx iff io issuecomment @@ -56,6 +73,7 @@ json keyring kib kwargs +lifecycle linters linting login @@ -70,26 +88,33 @@ plugins png pragma py +pyright pytest readme readthedocs +reco recognitions refactoring regex reimplementation +reportMissingTypeStubs +reportUnknownVariableType rfc rgb str timestamp todo +traceback travis txt unmocked +untyped url usefixtures validators vuforia vuforia's +vumark vwq vws xxx diff --git a/src/vws/__init__.py b/src/vws/__init__.py index 9f3b6f7f1..b4d39cc04 100644 --- a/src/vws/__init__.py +++ b/src/vws/__init__.py @@ -1,11 +1,21 @@ -""" -A library for Vuforia Web Services. -""" +"""A library for Vuforia Web Services.""" +from .async_model_target_service import AsyncModelTargetService +from .async_query import AsyncCloudRecoService +from .async_vumark_service import AsyncVuMarkService +from .async_vws import AsyncVWS +from .model_target_service import ModelTargetService from .query import CloudRecoService +from .vumark_service import VuMarkService from .vws import VWS __all__ = [ - 'CloudRecoService', - 'VWS', + "VWS", + "AsyncCloudRecoService", + "AsyncModelTargetService", + "AsyncVWS", + "AsyncVuMarkService", + "CloudRecoService", + "ModelTargetService", + "VuMarkService", ] diff --git a/src/vws/_async_vws_request.py b/src/vws/_async_vws_request.py new file mode 100644 index 000000000..bd8d91422 --- /dev/null +++ b/src/vws/_async_vws_request.py @@ -0,0 +1,77 @@ +"""Internal helper for making authenticated async requests to the +Vuforia Target API. +""" + +from beartype import BeartypeConf, beartype +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws.response import Response # noqa: TC001 +from vws.transports import AsyncTransport # noqa: TC001 + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +async def async_target_api_request( + *, + content_type: str, + server_access_key: str, + server_secret_key: str, + method: str, + data: bytes, + request_path: str, + base_vws_url: str, + request_timeout_seconds: float | tuple[float, float], + extra_headers: dict[str, str], + transport: AsyncTransport, +) -> Response: + """Make an async request to the Vuforia Target API. + + Args: + content_type: The content type of the request. + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + method: The HTTP method which will be used in the + request. + data: The request body which will be used in the + request. + request_path: The path to the endpoint which will be + used in the request. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for the request. + This can be a float to set both the connect and + read timeouts, or a (connect, read) tuple. + extra_headers: Additional headers to include in the + request. + transport: The async HTTP transport to use for the + request. + + Returns: + The response to the request. + """ + date_string = rfc_1123_date() + + signature_string = authorization_header( + access_key=server_access_key, + secret_key=server_secret_key, + method=method, + content=data, + content_type=content_type, + date=date_string, + request_path=request_path, + ) + + headers = { + "Authorization": signature_string, + "Date": date_string, + "Content-Type": content_type, + **extra_headers, + } + + url = base_vws_url.rstrip("/") + request_path + + return await transport( + method=method, + url=url, + headers=headers, + data=data, + request_timeout=request_timeout_seconds, + ) diff --git a/src/vws/_image_utils.py b/src/vws/_image_utils.py new file mode 100644 index 000000000..58ab58bfb --- /dev/null +++ b/src/vws/_image_utils.py @@ -0,0 +1,18 @@ +"""Image utility functions shared across VWS modules.""" + +import io +from typing import BinaryIO + +from beartype import beartype + +ImageType = io.BytesIO | BinaryIO + + +@beartype +def get_image_data(image: ImageType) -> bytes: + """Get the data of an image file.""" + original_tell = image.tell() + image.seek(0) + image_data = image.read() + image.seek(original_tell) + return image_data diff --git a/src/vws/_model_targets.py b/src/vws/_model_targets.py new file mode 100644 index 000000000..c391342c2 --- /dev/null +++ b/src/vws/_model_targets.py @@ -0,0 +1,323 @@ +"""Internal helpers for the Vuforia Model Target Web API.""" + +import base64 +import json +from collections.abc import Sequence # noqa: TC003 +from http import HTTPStatus +from typing import Any + +from beartype import BeartypeConf, beartype + +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.model_target_exceptions import ( + ModelTargetAuthenticationError, + ModelTargetDatasetNotDoneError, + ModelTargetError, + ModelTargetOAuth2Error, + ModelTargetValidationError, + UnknownModelTargetDatasetError, +) +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.model_target_datasets import ( # noqa: TC001 + ModelTargetDatasetType, + ModelTargetModel, + ModelTargetView, +) +from vws.reports import ModelTargetDatasetStatusReport +from vws.response import Response # noqa: TC001 + +OAUTH2_TOKEN_PATH = "/oauth2/token" # noqa: S105 +OAUTH2_TOKEN_BODY = b"grant_type=client_credentials" +OAUTH2_TOKEN_CONTENT_TYPE = "application/x-www-form-urlencoded" # noqa: S105 +JSON_CONTENT_TYPE = "application/json" + +_DATASET_COLLECTION_PATHS = { + "standard": "/modeltargets/datasets", + "advanced": "/modeltargets/advancedDatasets", +} +_EXCEPTIONS_BY_STATUS_CODE: dict[int, type[ModelTargetError]] = { + HTTPStatus.BAD_REQUEST: ModelTargetValidationError, + HTTPStatus.UNAUTHORIZED: ModelTargetAuthenticationError, + HTTPStatus.NOT_FOUND: UnknownModelTargetDatasetError, + HTTPStatus.UNPROCESSABLE_ENTITY: ModelTargetDatasetNotDoneError, +} + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def oauth2_token_headers( + *, client_id: str, client_secret: str +) -> dict[str, str]: + """Get the headers for a request for an access token. + + Args: + client_id: A Model Target Web API client ID. + client_secret: A Model Target Web API client secret. + + Returns: + The headers to send with a token request. + """ + credentials = f"{client_id}:{client_secret}".encode() + encoded_credentials = base64.b64encode(s=credentials).decode( + encoding="ascii", + ) + return { + "Authorization": f"Basic {encoded_credentials}", + "Content-Type": OAUTH2_TOKEN_CONTENT_TYPE, + } + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def access_token_from_response(*, response: Response) -> tuple[str, float]: + """Get an access token and its lifetime from a token response. + + Args: + response: The response from Vuforia's token endpoint. + + Returns: + The access token, and the number of seconds until it expires. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + if response.status_code != HTTPStatus.OK: + raise ModelTargetOAuth2Error(response=response) + + response_data = dict(json.loads(s=response.text)) + return response_data["access_token"], float(response_data["expires_in"]) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def dataset_collection_path(*, dataset_type: ModelTargetDatasetType) -> str: + """Get the path of the endpoint for datasets of a given type. + + Args: + dataset_type: The kind of dataset to get the path for. + + Returns: + The path of the dataset collection endpoint. + """ + return _DATASET_COLLECTION_PATHS[dataset_type.value] + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def dataset_path( + *, + dataset_type: ModelTargetDatasetType, + dataset_uuid: str, +) -> str: + """Get the path of the endpoint for one dataset. + + Args: + dataset_type: The kind of dataset to get the path for. + dataset_uuid: The UUID of the dataset. + + Returns: + The path of the dataset endpoint. + """ + collection_path = dataset_collection_path(dataset_type=dataset_type) + return f"{collection_path}/{dataset_uuid}" + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def dataset_status_path( + *, + dataset_type: ModelTargetDatasetType, + dataset_uuid: str, +) -> str: + """Get the path of the status endpoint for one dataset. + + Args: + dataset_type: The kind of dataset to get the path for. + dataset_uuid: The UUID of the dataset. + + Returns: + The path of the dataset status endpoint. + """ + return ( + dataset_path(dataset_type=dataset_type, dataset_uuid=dataset_uuid) + + "/status" + ) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def dataset_download_path( + *, + dataset_type: ModelTargetDatasetType, + dataset_uuid: str, +) -> str: + """Get the path of the download endpoint for one dataset. + + Args: + dataset_type: The kind of dataset to get the path for. + dataset_uuid: The UUID of the dataset. + + Returns: + The path of the dataset download endpoint. + """ + return ( + dataset_path(dataset_type=dataset_type, dataset_uuid=dataset_uuid) + + "/dataset" + ) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def _view_dict(*, view: ModelTargetView) -> dict[str, Any]: + """Get the request representation of a guide view. + + Args: + view: The guide view to represent. + + Returns: + The guide view, as it is sent to Vuforia. + """ + view_dict: dict[str, Any] = { + "name": view.name, + "guideViewPosition": { + "rotation": list(view.guide_view_position.rotation), + "translation": list(view.guide_view_position.translation), + }, + } + if view.states is not None: + view_dict["states"] = list(view.states) + + return view_dict + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def _model_dict(*, model: ModelTargetModel) -> dict[str, Any]: + """Get the request representation of a model. + + Args: + model: The model to represent. + + Returns: + The model, as it is sent to Vuforia. + """ + model_dict: dict[str, Any] = {"name": model.name} + optional_values: dict[str, str | None] = { + "automaticColoring": model.automatic_coloring, + "cadDataBlob": model.cad_data_blob, + "cadDataFormat": model.cad_data_format, + "cadDataUrl": model.cad_data_url, + "motionHint": model.motion_hint, + "optimizeTrackingFor": model.optimize_tracking_for, + "realisticAppearance": model.realistic_appearance, + "simplify": model.simplify, + "stateBasedConfigurationJsonString": ( + model.state_based_configuration_json_string + ), + "trackingMode": model.tracking_mode, + } + for field_name, value in optional_values.items(): + if value is not None: + model_dict[field_name] = str(object=value) + + if model.views is not None: + model_dict["views"] = [_view_dict(view=view) for view in model.views] + + return model_dict + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def dataset_request_body( + *, + name: str, + target_sdk: str, + models: Sequence[ModelTargetModel], +) -> bytes: + """Get the request body for creating a Model Target dataset. + + Args: + name: The name of the dataset. + target_sdk: The Vuforia Engine version to generate the dataset + for. + models: The models to generate the dataset from. + + Returns: + The body of the request. + """ + request_dict = { + "models": [_model_dict(model=model) for model in models], + "name": name, + "targetSdk": target_sdk, + } + return json.dumps(obj=request_dict).encode(encoding="utf-8") + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def raise_for_error(*, response: Response) -> None: + """Raise an exception for an unsuccessful Model Target Web API + response. + + Args: + response: A response from the Model Target Web API. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.ModelTargetValidationError: + Vuforia rejected the dataset creation request. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + ~vws.exceptions.model_target_exceptions.ModelTargetDatasetNotDoneError: + The dataset has not been generated. + ~vws.exceptions.model_target_exceptions.ModelTargetError: Vuforia + returned another error. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + if response.status_code < HTTPStatus.BAD_REQUEST: + return + + exception_type = _EXCEPTIONS_BY_STATUS_CODE.get( + response.status_code, + ModelTargetError, + ) + raise exception_type(response=response) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def dataset_uuid_from_response(*, response: Response) -> str: + """Get the UUID of a created dataset. + + Args: + response: A response to a dataset creation request. + + Returns: + The UUID of the created dataset. + """ + response_data = dict(json.loads(s=response.text)) + return str(object=response_data["uuid"]) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def status_report_from_response( + *, + response: Response, +) -> ModelTargetDatasetStatusReport: + """Get a dataset status report from a status response. + + Args: + response: A response to a dataset status request. + + Returns: + The status of the dataset. + """ + response_data = dict(json.loads(s=response.text)) + return ModelTargetDatasetStatusReport.from_response_dict( + response_dict=response_data, + ) diff --git a/src/vws/_reco_counts.py b/src/vws/_reco_counts.py new file mode 100644 index 000000000..6ae8010ac --- /dev/null +++ b/src/vws/_reco_counts.py @@ -0,0 +1,80 @@ +"""Internal helpers for the database reco counts report endpoints.""" + +import calendar # noqa: TC003 +import json +from http import HTTPStatus + +from beartype import BeartypeConf, beartype + +from vws.exceptions.custom_exceptions import ( + DatabaseIdNotSetError, + RecoCountsReportDownloadError, + RecoCountsReportNotReadyError, +) +from vws.reports import RecoCountsReport +from vws.response import Response # noqa: TC001 + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def reco_counts_report_path(*, database_id: str | None) -> str: + """Get the path of the reco counts report endpoint for a database. + + Args: + database_id: The ID of the database to get the path for. + + Returns: + The path of the reco counts report endpoint. + + Raises: + ~vws.exceptions.custom_exceptions.DatabaseIdNotSetError: No + ``database_id`` was given to the client. + """ + if database_id is None: + msg = ( + "A database ID is needed to request a reco counts report. Give " + "``database_id`` when creating the client." + ) + raise DatabaseIdNotSetError(msg) + + return f"/imagetargets/databases/{database_id}/reports/recoCounts" + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def reco_counts_report_body(*, year: int, month: calendar.Month) -> bytes: + """Get the request body for requesting a reco counts report. + + Args: + year: The year to request the report for. + month: The month of the year to request the report for. + + Returns: + The body of the request. + """ + month_string = f"{year:04d}-{month:02d}" + return json.dumps(obj={"month": month_string}).encode(encoding="utf-8") + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def report_from_download_response(*, response: Response) -> RecoCountsReport: + """Get a reco counts report from a response from a report's URL. + + Args: + response: The response from a report's download URL. + + Returns: + The downloaded report. + + Raises: + ~vws.exceptions.custom_exceptions.RecoCountsReportNotReadyError: + Vuforia has not finished generating the report. + ~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError: The + report could not be downloaded. For example, the report's URL may + have expired. + """ + if response.status_code == HTTPStatus.NOT_FOUND: + raise RecoCountsReportNotReadyError(response=response) + + if response.status_code != HTTPStatus.OK: + raise RecoCountsReportDownloadError(response=response) + + return RecoCountsReport.from_csv(csv_bytes=response.content) diff --git a/src/vws/_vws_request.py b/src/vws/_vws_request.py new file mode 100644 index 000000000..3153dce0c --- /dev/null +++ b/src/vws/_vws_request.py @@ -0,0 +1,76 @@ +"""Internal helper for making authenticated requests to the Vuforia Target +API. +""" + +from beartype import BeartypeConf, beartype +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws.response import Response # noqa: TC001 +from vws.transports import Transport # noqa: TC001 + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def target_api_request( + *, + content_type: str, + server_access_key: str, + server_secret_key: str, + method: str, + data: bytes, + request_path: str, + base_vws_url: str, + request_timeout_seconds: float | tuple[float, float], + extra_headers: dict[str, str], + transport: Transport, +) -> Response: + """Make a request to the Vuforia Target API. + + Args: + content_type: The content type of the request. + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + method: The HTTP method which will be used in the + request. + data: The request body which will be used in the + request. + request_path: The path to the endpoint which will be + used in the request. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for the request. + This can be a float to set both the connect and + read timeouts, or a (connect, read) tuple. + extra_headers: Additional headers to include in the + request. + transport: The HTTP transport to use for the request. + + Returns: + The response to the request. + """ + date_string = rfc_1123_date() + + signature_string = authorization_header( + access_key=server_access_key, + secret_key=server_secret_key, + method=method, + content=data, + content_type=content_type, + date=date_string, + request_path=request_path, + ) + + headers = { + "Authorization": signature_string, + "Date": date_string, + "Content-Type": content_type, + **extra_headers, + } + + url = base_vws_url.rstrip("/") + request_path + + return transport( + method=method, + url=url, + headers=headers, + data=data, + request_timeout=request_timeout_seconds, + ) diff --git a/src/vws/async_model_target_service.py b/src/vws/async_model_target_service.py new file mode 100644 index 000000000..67a5e901c --- /dev/null +++ b/src/vws/async_model_target_service.py @@ -0,0 +1,381 @@ +"""Async interface to the Vuforia Model Target Web API.""" + +import asyncio +import time +from collections.abc import Sequence # noqa: TC003 +from http import HTTPMethod +from typing import Self + +from beartype import BeartypeConf, beartype + +from vws._model_targets import ( + JSON_CONTENT_TYPE, + OAUTH2_TOKEN_BODY, + OAUTH2_TOKEN_PATH, + access_token_from_response, + dataset_collection_path, + dataset_download_path, + dataset_path, + dataset_request_body, + dataset_status_path, + dataset_uuid_from_response, + oauth2_token_headers, + raise_for_error, + status_report_from_response, +) +from vws.exceptions.model_target_exceptions import ( + ModelTargetDatasetTimeoutError, +) +from vws.model_target_datasets import ( # noqa: TC001 + ModelTargetDatasetType, + ModelTargetModel, +) +from vws.reports import ( + ModelTargetDatasetStatuses, + ModelTargetDatasetStatusReport, +) +from vws.response import Response # noqa: TC001 +from vws.transports import AsyncHTTPXTransport, AsyncTransport + +_TOKEN_EXPIRY_MARGIN_SECONDS = 60.0 + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class AsyncModelTargetService: + """An async interface to the Vuforia Model Target Web API.""" + + def __init__( + self, + *, + client_id: str, + client_secret: str, + base_vws_url: str = "https://vws.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: AsyncTransport | None = None, + ) -> None: + """ + Args: + client_id: A Model Target Web API OAuth2 client + ID. + client_secret: A Model Target Web API OAuth2 + client secret. + base_vws_url: The base URL for the VWS API, which + also serves the Model Target Web API. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The async HTTP transport to use for + requests. Defaults to + ``AsyncHTTPXTransport()``. + """ + self._client_id = client_id + self._client_secret = client_secret + self._base_vws_url = base_vws_url + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else AsyncHTTPXTransport() + ) + self._access_token: str | None = None + self._access_token_expiry_time = 0.0 + + async def aclose(self) -> None: + """Close the underlying transport if it supports closing.""" + await self._transport.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *_args: object) -> None: + """Exit the async context manager and close the transport.""" + await self.aclose() + + async def get_access_token(self) -> str: + """Get an OAuth2 access token for the Model Target Web API. + + A token is requested only when the client has no token which is + still valid, so this can be called before each request. + + Returns: + A bearer token. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. For example, the + given client ID and client secret may not match a set of + Model Target Web API credentials. + """ + request_time = time.monotonic() + if ( + self._access_token is not None + and request_time < self._access_token_expiry_time + ): + return self._access_token + + response = await self._transport( + method=HTTPMethod.POST, + url=self._base_vws_url.rstrip("/") + OAUTH2_TOKEN_PATH, + headers=oauth2_token_headers( + client_id=self._client_id, + client_secret=self._client_secret, + ), + data=OAUTH2_TOKEN_BODY, + request_timeout=self._request_timeout_seconds, + ) + + access_token, expires_in_seconds = access_token_from_response( + response=response, + ) + self._access_token = access_token + self._access_token_expiry_time = ( + request_time + expires_in_seconds - _TOKEN_EXPIRY_MARGIN_SECONDS + ) + return access_token + + async def make_request( + self, + *, + method: str, + data: bytes, + request_path: str, + extra_headers: dict[str, str] | None = None, + ) -> Response: + """Make an authenticated request to the Model Target Web API. + + Args: + method: The HTTP method which will be used in + the request. + data: The request body which will be used in the + request. + request_path: The path to the endpoint which + will be used in the request. + extra_headers: Additional headers to include in + the request. + + Returns: + The response to the request. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetError: + Vuforia returned an error. + ~vws.exceptions.custom_exceptions.ServerError: + There is an error with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: + Vuforia is rate limiting access. + """ + access_token = await self.get_access_token() + headers = { + "Authorization": f"Bearer {access_token}", + **(extra_headers or {}), + } + + response = await self._transport( + method=method, + url=self._base_vws_url.rstrip("/") + request_path, + headers=headers, + data=data, + request_timeout=self._request_timeout_seconds, + ) + + raise_for_error(response=response) + return response + + async def create_dataset( + self, + *, + name: str, + target_sdk: str, + models: Sequence[ModelTargetModel], + dataset_type: ModelTargetDatasetType, + ) -> str: + """Start generating a Model Target dataset. + + Vuforia generates the dataset in the background, so it is not + available to download immediately. Use + :meth:`wait_for_dataset_generated` to wait for it. + + Args: + name: The name of the dataset. + target_sdk: The Vuforia Engine version to generate the dataset + for. + models: The models to generate the dataset from. A standard + dataset takes exactly one model. + dataset_type: Whether to create a standard or an advanced + dataset. + + Returns: + The UUID of the new dataset. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.ModelTargetValidationError: + Vuforia rejected the request. For example, a model may + give neither a CAD data URL nor a CAD data blob. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + response = await self.make_request( + method=HTTPMethod.POST, + data=dataset_request_body( + name=name, + target_sdk=target_sdk, + models=models, + ), + request_path=dataset_collection_path(dataset_type=dataset_type), + extra_headers={"Content-Type": JSON_CONTENT_TYPE}, + ) + + return dataset_uuid_from_response(response=response) + + async def get_dataset_status( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + ) -> ModelTargetDatasetStatusReport: + """Get the status of a Model Target dataset. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to get the status of. + + Returns: + The status of the dataset. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + response = await self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=dataset_status_path( + dataset_type=dataset_type, + dataset_uuid=dataset_uuid, + ), + ) + + return status_report_from_response(response=response) + + async def wait_for_dataset_generated( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + seconds_between_requests: float = 0.2, + timeout_seconds: float = 60 * 5, + ) -> ModelTargetDatasetStatusReport: + """Wait for Vuforia to finish generating a Model Target dataset. + + A dataset which failed to generate is also finished, so the + returned report may have a + :attr:`~.ModelTargetDatasetStatusReport.status` of + ``FAILED``. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to wait for. + seconds_between_requests: The number of seconds to wait between + requests made while polling the dataset's status. + timeout_seconds: The maximum number of seconds to wait for the + dataset to be generated. + + Returns: + The status of the dataset once it is no longer processing. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetDatasetTimeoutError: + The dataset was not generated within ``timeout_seconds`` + seconds. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + """ + start_time = time.monotonic() + while True: + report = await self.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + if report.status != ModelTargetDatasetStatuses.PROCESSING: + return report + + elapsed_time = time.monotonic() - start_time + if elapsed_time > timeout_seconds: + raise ModelTargetDatasetTimeoutError + + await asyncio.sleep(delay=seconds_between_requests) + + async def download_dataset( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + ) -> bytes: + """Download a generated Model Target dataset. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to download. + + Returns: + The dataset, as the bytes of a zip file. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + ~vws.exceptions.model_target_exceptions.ModelTargetDatasetNotDoneError: + Vuforia has not generated the dataset. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + response = await self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=dataset_download_path( + dataset_type=dataset_type, + dataset_uuid=dataset_uuid, + ), + ) + + return response.content + + async def delete_dataset( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + ) -> None: + """Delete a Model Target dataset. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to delete. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + await self.make_request( + method=HTTPMethod.DELETE, + data=b"", + request_path=dataset_path( + dataset_type=dataset_type, + dataset_uuid=dataset_uuid, + ), + ) diff --git a/src/vws/async_query.py b/src/vws/async_query.py new file mode 100644 index 000000000..ec0704ef4 --- /dev/null +++ b/src/vws/async_query.py @@ -0,0 +1,224 @@ +"""Async tools for interacting with the Vuforia Cloud Recognition +Web APIs. +""" + +import json +from http import HTTPMethod, HTTPStatus +from typing import Any, Self + +from beartype import BeartypeConf, beartype +from urllib3.filepost import encode_multipart_formdata +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws._image_utils import ImageType as _ImageType +from vws._image_utils import get_image_data as _get_image_data +from vws.exceptions.base_exceptions import CloudRecoError +from vws.exceptions.cloud_reco_exceptions import ( + AuthenticationFailureError, + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, + RequestTimeTooSkewedError, +) +from vws.exceptions.custom_exceptions import ( + RequestEntityTooLargeError, + ServerError, +) +from vws.include_target_data import CloudRecoIncludeTargetData +from vws.reports import QueryResult +from vws.transports import AsyncHTTPXTransport, AsyncTransport + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class AsyncCloudRecoService: + """An async interface to the Vuforia Cloud Recognition Web + APIs. + """ + + def __init__( + self, + *, + client_access_key: str, + client_secret_key: str, + base_vwq_url: str = "https://cloudreco.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: AsyncTransport | None = None, + ) -> None: + """ + Args: + client_access_key: A VWS client access key. + client_secret_key: A VWS client secret key. + base_vwq_url: The base URL for the VWQ API. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The async HTTP transport to use for + requests. Defaults to + ``AsyncHTTPXTransport()``. + """ + self._client_access_key = client_access_key + self._client_secret_key = client_secret_key + self._base_vwq_url = base_vwq_url + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else AsyncHTTPXTransport() + ) + + async def aclose(self) -> None: + """Close the underlying transport if it supports closing.""" + await self._transport.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *_args: object) -> None: + """Exit the async context manager and close the transport.""" + await self.aclose() + + async def query( + self, + *, + image: _ImageType, + max_num_results: int = 1, + include_target_data: CloudRecoIncludeTargetData = ( + CloudRecoIncludeTargetData.TOP + ), + ) -> list[QueryResult]: + """Use the Vuforia Web Query API to make an Image + Recognition Query. + + See + https://developer.vuforia.com/library/web-api/vuforia-query-web-api + for parameter details. + + Args: + image: The image to make a query against. + max_num_results: The maximum number of matching + targets to be returned. + include_target_data: Indicates if target_data + records shall be returned for the matched + targets. Accepted values are top (default + value, only return target_data for top ranked + match), none (return no target_data), all + (for all matched targets). + + Raises: + ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError: + The client access key pair is not correct. + ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError: + ``max_num_results`` is not within the range (1, 50). + ~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The + project is inactive. + ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError: + There is an error with the time sent to Vuforia. + ~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a + problem with the given image. For example, it must be a JPEG or + PNG file in the grayscale or RGB color space. + ~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The + given image is too large. + ~vws.exceptions.custom_exceptions.ServerError: There is an + error with Vuforia's servers. + ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned + a client error without a recognized JSON body. + json.JSONDecodeError: Vuforia returned a successful response with + an invalid JSON body. + + Returns: + An ordered list of target details of matching + targets. + """ + image_content = _get_image_data(image=image) + body: dict[str, Any] = { + "image": ( + "image.jpeg", + image_content, + "image/jpeg", + ), + "max_num_results": ( + None, + int(max_num_results), + "text/plain", + ), + "include_target_data": ( + None, + include_target_data.value, + "text/plain", + ), + } + date = rfc_1123_date() + request_path = "/v1/query" + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + authorization_string = authorization_header( + access_key=self._client_access_key, + secret_key=self._client_secret_key, + method=method, + content=content, + # Note that this is not the actual Content-Type + # header value sent. + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + } + + response = await self._transport( + method=method, + url=self._base_vwq_url.rstrip("/") + request_path, + headers=headers, + data=content, + request_timeout=self._request_timeout_seconds, + ) + + if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: + raise RequestEntityTooLargeError(response=response) + + if "Integer out of range" in response.text: + raise MaxNumResultsOutOfRangeError( + response=response, + ) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + content_type = { + key.lower(): value for key, value in response.headers.items() + }.get("content-type", "") + if ( + response.status_code >= HTTPStatus.BAD_REQUEST + and not content_type.lower().startswith("application/json") + ): + raise CloudRecoError(response=response) + + try: + response_body = json.loads(s=response.text) + except json.JSONDecodeError as exc: + if response.status_code >= HTTPStatus.BAD_REQUEST: + raise CloudRecoError(response=response) from exc + raise + + result_code = response_body["result_code"] + if result_code != "Success": + exception = { + "AuthenticationFailure": (AuthenticationFailureError), + "BadImage": BadImageError, + "InactiveProject": InactiveProjectError, + "RequestTimeTooSkewed": (RequestTimeTooSkewedError), + }[result_code] + raise exception(response=response) + + result_list = list(response_body["results"]) + return [ + QueryResult.from_response_dict(response_dict=item) + for item in result_list + ] diff --git a/src/vws/async_vumark_service.py b/src/vws/async_vumark_service.py new file mode 100644 index 000000000..6575f7716 --- /dev/null +++ b/src/vws/async_vumark_service.py @@ -0,0 +1,155 @@ +"""Async interface to the Vuforia VuMark Generation Web API.""" + +import json +from http import HTTPMethod, HTTPStatus +from typing import Self + +from beartype import BeartypeConf, beartype + +from vws._async_vws_request import async_target_api_request +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.transports import AsyncHTTPXTransport, AsyncTransport +from vws.vumark_accept import VuMarkAccept # noqa: TC001 + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class AsyncVuMarkService: + """An async interface to the Vuforia VuMark Generation Web + API. + """ + + def __init__( + self, + *, + server_access_key: str, + server_secret_key: str, + base_vws_url: str = "https://vws.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: AsyncTransport | None = None, + ) -> None: + """ + Args: + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The async HTTP transport to use for + requests. Defaults to + ``AsyncHTTPXTransport()``. + """ + self._server_access_key = server_access_key + self._server_secret_key = server_secret_key + self._base_vws_url = base_vws_url + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else AsyncHTTPXTransport() + ) + + async def aclose(self) -> None: + """Close the underlying transport if it supports closing.""" + await self._transport.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *_args: object) -> None: + """Exit the async context manager and close the transport.""" + await self.aclose() + + async def generate_vumark_instance( + self, + *, + target_id: str, + instance_id: str, + accept: VuMarkAccept, + ) -> bytes: + """Generate a VuMark instance image. + + See + https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ + for parameter details. + + Args: + target_id: The ID of the VuMark target. + instance_id: The instance ID to encode in the + VuMark. + accept: The image format to return. + + Returns: + The VuMark instance image bytes. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.AuthorizationFailedError: There was + a general authentication problem. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.InvalidAcceptHeaderError: The + Accept header value is not supported. + ~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The + instance ID is invalid. For example, it may be empty. + ~vws.exceptions.vws_exceptions.InvalidTargetTypeError: The target + is not a VuMark template target. + ~vws.exceptions.vws_exceptions.LicenseCheckFailedError: The + license state and/or type does not allow this request. + ~vws.exceptions.vws_exceptions.QuotaExceededError: No more + instances can be created for the associated license. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The + target is not in the success state. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + request_path = f"/targets/{target_id}/instances" + content_type = "application/json" + request_data = json.dumps( + obj={"instance_id": instance_id}, + ).encode(encoding="utf-8") + + response = await async_target_api_request( + content_type=content_type, + server_access_key=self._server_access_key, + server_secret_key=self._server_secret_key, + method=HTTPMethod.POST, + data=request_data, + request_path=request_path, + base_vws_url=self._base_vws_url, + request_timeout_seconds=(self._request_timeout_seconds), + extra_headers={"Accept": accept}, + transport=self._transport, + ) + + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no + # JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + if response.status_code == HTTPStatus.OK: + return response.content + + result_code = json.loads(s=response.text)["result_code"] + + raise VWSError.from_result_code( + result_code=result_code, + response=response, + ) diff --git a/src/vws/async_vws.py b/src/vws/async_vws.py new file mode 100644 index 000000000..d2306a63b --- /dev/null +++ b/src/vws/async_vws.py @@ -0,0 +1,752 @@ +"""Async tools for interacting with Vuforia APIs.""" + +import asyncio +import base64 +import calendar # noqa: TC003 +import json +import time +from http import HTTPMethod, HTTPStatus +from typing import Self + +from beartype import BeartypeConf, beartype + +from vws._async_vws_request import async_target_api_request +from vws._image_utils import ImageType as _ImageType +from vws._image_utils import get_image_data as _get_image_data +from vws._reco_counts import ( + reco_counts_report_body, + reco_counts_report_path, + report_from_download_response, +) +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.custom_exceptions import ( + RecoCountsReportNotReadyError, + RecoCountsReportTimeoutError, + ServerError, + TargetProcessingTimeoutError, +) +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.reports import ( + DatabaseSummaryReport, + RecoCountsReport, + RecoCountsReportRequest, + TargetStatusAndRecord, + TargetStatuses, + TargetSummaryReport, +) +from vws.response import Response # noqa: TC001 +from vws.transports import AsyncHTTPXTransport, AsyncTransport + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class AsyncVWS: + """An async interface to Vuforia Web Services APIs.""" + + def __init__( + self, + *, + server_access_key: str, + server_secret_key: str, + base_vws_url: str = "https://vws.vuforia.com", + database_id: str | None = None, + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: AsyncTransport | None = None, + ) -> None: + """ + Args: + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + base_vws_url: The base URL for the VWS API. + database_id: The ID of the database which the + given keys belong to. This is shown in the + target manager. It is needed only by + :meth:`request_database_reco_counts_report`. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The async HTTP transport to use for + requests. Defaults to + ``AsyncHTTPXTransport()``. + """ + self._server_access_key = server_access_key + self._server_secret_key = server_secret_key + self._base_vws_url = base_vws_url + self._database_id = database_id + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else AsyncHTTPXTransport() + ) + + async def aclose(self) -> None: + """Close the underlying transport if it supports closing.""" + await self._transport.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *_args: object) -> None: + """Exit the async context manager and close the transport.""" + await self.aclose() + + async def make_request( + self, + *, + method: str, + data: bytes, + request_path: str, + expected_result_code: str, + content_type: str, + extra_headers: dict[str, str] | None = None, + ) -> Response: + """Make an async request to the Vuforia Target API. + + Args: + method: The HTTP method which will be used in + the request. + data: The request body which will be used in the + request. + request_path: The path to the endpoint which + will be used in the request. + expected_result_code: See + "VWS API Result Codes" on + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. + content_type: The content type of the request. + extra_headers: Additional headers to include in + the request. + + Returns: + The response to the request. + + Raises: + ~vws.exceptions.custom_exceptions.ServerError: + There is an error with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: + Vuforia is rate limiting access. + json.JSONDecodeError: The server did not respond + with valid JSON. This may happen if the + server address is not a valid Vuforia server. + """ + response = await async_target_api_request( + content_type=content_type, + server_access_key=self._server_access_key, + server_secret_key=self._server_secret_key, + method=method, + data=data, + request_path=request_path, + base_vws_url=self._base_vws_url, + request_timeout_seconds=self._request_timeout_seconds, + extra_headers=extra_headers or {}, + transport=self._transport, + ) + + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + result_code = json.loads(s=response.text)["result_code"] + + if result_code == expected_result_code: + return response + + raise VWSError.from_result_code( + result_code=result_code, + response=response, + ) + + async def add_target( + self, + *, + name: str, + width: float, + image: _ImageType, + application_metadata: str | None, + active_flag: bool, + ) -> str: + """Add a target to a Vuforia Web Services database. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add + for parameter details. + + Args: + name: The name of the target. + width: The width of the target. + image: The image of the target. + active_flag: Whether or not the target is active for query. + application_metadata: The application metadata of the target. + This must be base64 encoded, for example by using:: + + base64.b64encode('input_string').decode('ascii') + + Returns: + The target ID of the new target. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.BadImageError: There is a problem + with the given image. For example, it must be a JPEG or PNG + file in the grayscale or RGB color space. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given + metadata is too large. The maximum size is 1 MB of data when + Base64 encoded. + ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image + is too large. + ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with + the given ``name`` already exists. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is + inactive. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. This has been seen to happen when the + given name includes a bad character. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + image_data = _get_image_data(image=image) + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + + data = { + "name": name, + "width": width, + "image": image_data_encoded, + "active_flag": active_flag, + "application_metadata": application_metadata, + } + + content = json.dumps(obj=data).encode(encoding="utf-8") + + response = await self.make_request( + method=HTTPMethod.POST, + data=content, + request_path="/targets", + expected_result_code="TargetCreated", + content_type="application/json", + ) + + return str(object=json.loads(s=response.text)["target_id"]) + + async def get_target_record(self, target_id: str) -> TargetStatusAndRecord: + """Get a given target's target record from the Target + Management System. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. + + Args: + target_id: The ID of the target to get details of. + + Returns: + Response details of a target from Vuforia. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + response = await self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=f"/targets/{target_id}", + expected_result_code="Success", + content_type="application/json", + ) + + result_data = json.loads(s=response.text) + return TargetStatusAndRecord.from_response_dict( + response_dict=result_data, + ) + + async def wait_for_target_processed( + self, + *, + target_id: str, + seconds_between_requests: float = 0.2, + timeout_seconds: float = 60 * 5, + ) -> None: + """Wait up to five minutes (arbitrary) for a target to + get past the processing stage. + + Args: + target_id: The ID of the target to wait for. + seconds_between_requests: The number of seconds to + wait between requests made while polling the + target status. + We wait 0.2 seconds by default, rather than + less, than that to decrease the number of calls + made to the API, to decrease the likelihood of + hitting the request quota. + timeout_seconds: The maximum number of seconds to + wait for the target to be processed. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.custom_exceptions.TargetProcessingTimeoutError: The + target remained in the processing stage for more than + ``timeout_seconds`` seconds. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + start_time = asyncio.get_event_loop().time() + while True: + report = await self.get_target_summary_report( + target_id=target_id, + ) + if report.status != TargetStatuses.PROCESSING: + # Guard against the target still being seen as + # processing by other endpoints due to eventual + # consistency. + await asyncio.sleep( + delay=seconds_between_requests, + ) + return + + elapsed_time = asyncio.get_event_loop().time() - start_time + if elapsed_time > timeout_seconds: # pragma: no cover + raise TargetProcessingTimeoutError + + await asyncio.sleep( + delay=seconds_between_requests, + ) + + async def list_targets(self) -> list[str]: + """List target IDs. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list. + + Returns: + The IDs of all targets in the database. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + response = await self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path="/targets", + expected_result_code="Success", + content_type="application/json", + ) + + return list(json.loads(s=response.text)["results"]) + + async def get_target_summary_report( + self, target_id: str + ) -> TargetSummaryReport: + """Get a summary report for a target. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. + + Args: + target_id: The ID of the target to get a summary + report for. + + Returns: + Details of the target. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + response = await self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=f"/summary/{target_id}", + expected_result_code="Success", + content_type="application/json", + ) + + result_data = dict(json.loads(s=response.text)) + return TargetSummaryReport.from_response_dict( + response_dict=result_data, + ) + + async def get_database_summary_report( + self, + ) -> DatabaseSummaryReport: + """Get a summary report for the database. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. + + Returns: + Details of the database. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + response = await self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path="/summary", + expected_result_code="Success", + content_type="application/json", + ) + + response_data = dict(json.loads(s=response.text)) + return DatabaseSummaryReport.from_response_dict( + response_dict=response_data, + ) + + async def request_database_reco_counts_report( + self, + *, + year: int, + month: calendar.Month, + ) -> RecoCountsReportRequest: + """Request a per-target recognition count report for the database. + + Vuforia generates the report in the background, so the report is not + available to download immediately. Use + :meth:`wait_for_reco_counts_report` to wait for it. + + Args: + year: The year to get recognition counts for. + month: The month of the year to get recognition counts for. + Vuforia accepts only the current month and the previous + month. A month taken from a :class:`datetime.datetime` needs + wrapping, as in ``calendar.Month(value=now.month)``. + + Returns: + The URL to download the report from, and the transaction ID of + the request. + + Raises: + ~vws.exceptions.custom_exceptions.DatabaseIdNotSetError: No + ``database_id`` was given to the client. + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct, or the client's ``database_id`` is + not the ID of the database which the client's keys belong to. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given year and month are not + the current month or the previous month. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + response = await self.make_request( + method=HTTPMethod.POST, + data=reco_counts_report_body(year=year, month=month), + request_path=reco_counts_report_path( + database_id=self._database_id, + ), + expected_result_code="Success", + content_type="application/json", + ) + + response_data = dict(json.loads(s=response.text)) + return RecoCountsReportRequest.from_response_dict( + response_dict=response_data, + ) + + async def download_reco_counts_report( + self, + *, + presigned_url: str, + ) -> RecoCountsReport: + """Download a requested reco counts report. + + The report's URL is not part of the VWS API, so this request is not + authorized with the client's keys. + + Args: + presigned_url: The URL of the report, as given by + :meth:`request_database_reco_counts_report`. + + Returns: + The downloaded report. + + Raises: + ~vws.exceptions.custom_exceptions.RecoCountsReportNotReadyError: + Vuforia has not finished generating the report. + ~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError: + The report could not be downloaded. For example, the report's + URL may have expired. + """ + response = await self._transport( + method=HTTPMethod.GET, + url=presigned_url, + headers={}, + data=b"", + request_timeout=self._request_timeout_seconds, + ) + + return report_from_download_response(response=response) + + async def wait_for_reco_counts_report( + self, + *, + presigned_url: str, + seconds_between_requests: float = 0.2, + timeout_seconds: float = 60 * 5, + ) -> RecoCountsReport: + """Wait for a requested reco counts report to be generated, then + download it. + + Args: + presigned_url: The URL of the report, as given by + :meth:`request_database_reco_counts_report`. + seconds_between_requests: The number of seconds to wait between + requests made while polling the report's URL. + timeout_seconds: The maximum number of seconds to wait for the + report to be generated. + + Returns: + The downloaded report. + + Raises: + ~vws.exceptions.custom_exceptions.RecoCountsReportTimeoutError: + The report was not generated within ``timeout_seconds`` + seconds. + ~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError: + The report could not be downloaded. For example, the report's + URL may have expired. + """ + start_time = time.monotonic() + while True: + try: + return await self.download_reco_counts_report( + presigned_url=presigned_url, + ) + except RecoCountsReportNotReadyError: + elapsed_time = time.monotonic() - start_time + if elapsed_time > timeout_seconds: + raise RecoCountsReportTimeoutError from None + + await asyncio.sleep(delay=seconds_between_requests) + + async def delete_target(self, target_id: str) -> None: + """Delete a given target. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete. + + Args: + target_id: The ID of the target to delete. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.TargetStatusProcessingError: The + given target is in the processing state. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + await self.make_request( + method=HTTPMethod.DELETE, + data=b"", + request_path=f"/targets/{target_id}", + expected_result_code="Success", + content_type="application/json", + ) + + async def get_duplicate_targets(self, target_id: str) -> list[str]: + """Get targets which may be considered duplicates of a + given target. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check. + + Args: + target_id: The ID of the target to delete. + + Returns: + The target IDs of duplicate targets. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is + inactive. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + response = await self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=f"/duplicates/{target_id}", + expected_result_code="Success", + content_type="application/json", + ) + + return list( + json.loads(s=response.text)["similar_targets"], + ) + + async def update_target( + self, + *, + target_id: str, + name: str | None = None, + width: float | None = None, + image: _ImageType | None = None, + active_flag: bool | None = None, + application_metadata: str | None = None, + ) -> None: + """Update a target in a Vuforia Web Services database. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update + for parameter details. + + Args: + target_id: The ID of the target to update. + name: The name of the target. + width: The width of the target. + image: The image of the target. + active_flag: Whether or not the target is active + for query. + application_metadata: The application metadata of + the target. + This must be base64 encoded, for example by + using:: + + base64.b64encode('input_string').decode('ascii') + + Giving ``None`` will not change the application + metadata. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.BadImageError: There is a problem + with the given image. For example, it must be a JPEG or PNG + file in the grayscale or RGB color space. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given + metadata is too large. The maximum size is 1 MB of data when + Base64 encoded. + ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image + is too large. + ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with + the given ``name`` already exists. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is + inactive. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + data: dict[str, str | bool | float | int] = {} + + if name is not None: + data["name"] = name + + if width is not None: + data["width"] = width + + if image is not None: + image_data = _get_image_data(image=image) + image_data_encoded = base64.b64encode( + s=image_data, + ).decode(encoding="ascii") + data["image"] = image_data_encoded + + if active_flag is not None: + data["active_flag"] = active_flag + + if application_metadata is not None: + data["application_metadata"] = application_metadata + + content = json.dumps(obj=data).encode(encoding="utf-8") + + await self.make_request( + method=HTTPMethod.PUT, + data=content, + request_path=f"/targets/{target_id}", + expected_result_code="Success", + content_type="application/json", + ) diff --git a/src/vws/exceptions/__init__.py b/src/vws/exceptions/__init__.py index a1e3cd91c..7260ffdbb 100644 --- a/src/vws/exceptions/__init__.py +++ b/src/vws/exceptions/__init__.py @@ -1,3 +1 @@ -""" -Custom exceptions raised by this package. -""" +"""Custom exceptions raised by this package.""" diff --git a/src/vws/exceptions/base_exceptions.py b/src/vws/exceptions/base_exceptions.py index 66c0ae70c..616e0682f 100644 --- a/src/vws/exceptions/base_exceptions.py +++ b/src/vws/exceptions/base_exceptions.py @@ -1,15 +1,20 @@ """ -Base exceptions for errors returned by Vuforia Web Services or the Vuforia +Base exceptions for errors returned by Vuforia Web Services or the +Vuforia Cloud Recognition Web API. """ -from requests import Response +from collections.abc import Mapping # noqa: TC003 +from typing import ClassVar +from beartype import beartype + +from vws.response import Response # noqa: TC001 -class CloudRecoException(Exception): - """ - Base class for Vuforia Cloud Recognition Web API exceptions. - """ + +@beartype +class CloudRecoError(Exception): + """Base class for Vuforia Cloud Recognition Web API exceptions.""" def __init__(self, response: Response) -> None: """ @@ -21,20 +26,20 @@ def __init__(self, response: Response) -> None: @property def response(self) -> Response: - """ - The response returned by Vuforia which included this error. - """ + """The response returned by Vuforia which included this error.""" return self._response -class VWSException(Exception): - """ - Base class for Vuforia Web Services errors. +@beartype +class VWSError(Exception): + """Base class for Vuforia Web Services errors. These errors are defined at - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Interperete-VWS-API-Result-Codes. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. """ + _exceptions_by_result_code: ClassVar[dict[str, type[VWSError]]] = {} + def __init__(self, response: Response) -> None: """ Args: @@ -43,9 +48,27 @@ def __init__(self, response: Response) -> None: super().__init__() self._response = response + @classmethod + def register_exceptions_by_result_code( + cls, + *, + exceptions_by_result_code: Mapping[str, type[VWSError]], + ) -> None: + """Register ``result_code`` to exception mappings.""" + cls._exceptions_by_result_code.update(exceptions_by_result_code) + + @classmethod + def from_result_code( + cls, + *, + result_code: str, + response: Response, + ) -> VWSError: + """Create the mapped exception for a VWS ``result_code``.""" + exception_type = cls._exceptions_by_result_code[result_code] + return exception_type(response=response) + @property def response(self) -> Response: - """ - The response returned by Vuforia which included this error. - """ + """The response returned by Vuforia which included this error.""" return self._response diff --git a/src/vws/exceptions/cloud_reco_exceptions.py b/src/vws/exceptions/cloud_reco_exceptions.py index 6221bef03..b2e3ee67f 100644 --- a/src/vws/exceptions/cloud_reco_exceptions.py +++ b/src/vws/exceptions/cloud_reco_exceptions.py @@ -1,48 +1,44 @@ +"""Exceptions which match errors raised by the Vuforia Cloud Recognition +Web +APIs. """ -Exceptions which match errors raised by the Vuforia Cloud Recognition Web APIs. -""" - - -from vws.exceptions.base_exceptions import CloudRecoException +from beartype import beartype -class MatchProcessing(CloudRecoException): - """ - Exception raised when a query is made with an image which matches a target - which is processing or has recently been deleted. - """ +from vws.exceptions.base_exceptions import CloudRecoError -class MaxNumResultsOutOfRange(CloudRecoException): +@beartype +class MaxNumResultsOutOfRangeError(CloudRecoError): """ Exception raised when the ``max_num_results`` given to the Cloud Recognition Web API query endpoint is out of range. """ -class InactiveProject(CloudRecoException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class InactiveProjectError(CloudRecoError): + """Exception raised when Vuforia returns a response with a result code 'InactiveProject'. """ -class BadImage(CloudRecoException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class BadImageError(CloudRecoError): + """Exception raised when Vuforia returns a response with a result code 'BadImage'. """ -class AuthenticationFailure(CloudRecoException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class AuthenticationFailureError(CloudRecoError): + """Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. """ -class RequestTimeTooSkewed(CloudRecoException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class RequestTimeTooSkewedError(CloudRecoError): + """Exception raised when Vuforia returns a response with a result code 'RequestTimeTooSkewed'. """ diff --git a/src/vws/exceptions/custom_exceptions.py b/src/vws/exceptions/custom_exceptions.py index 5fc61ac91..70ec81e36 100644 --- a/src/vws/exceptions/custom_exceptions.py +++ b/src/vws/exceptions/custom_exceptions.py @@ -1,29 +1,107 @@ -""" -Exceptions which do not map to errors at -https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Interperete-VWS-API-Result-Codes -or simple errors given by the cloud recognition service. +"""Exceptions which do not map to errors at the following URL, or simple +errors given by the cloud recognition service. + +https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes """ -import requests +from beartype import beartype +from vws.response import Response # noqa: TC001 -class UnknownVWSErrorPossiblyBadName(Exception): - """ - Exception raised when VWS returns an HTML page which says "Oops, an error - occurred". - This has been seen to happen when the given name includes a bad character. +@beartype +class RequestEntityTooLargeError(Exception): + """Exception raised when the given image is too large.""" + + def __init__(self, response: Response) -> None: + """ + Args: + response: The response returned by Vuforia. + """ + super().__init__(response.text) + self._response = response + + @property + def response(self) -> Response: + """The response returned by Vuforia which included this error.""" + return self._response + + +@beartype +class TargetProcessingTimeoutError(Exception): + """Exception raised when waiting for a target to be processed times + out. """ -class ConnectionErrorPossiblyImageTooLarge(requests.ConnectionError): +@beartype +class DatabaseIdNotSetError(Exception): + """Exception raised when an operation which needs a database ID is used + on a client which was not given one. """ - Exception raised when a ConnectionError is raised from a query. This has - been seen to happen when the given image is too large. + + +@beartype +class RecoCountsReportNotReadyError(Exception): + """Exception raised when a reco counts report is downloaded before + Vuforia has generated it. """ + def __init__(self, response: Response) -> None: + """ + Args: + response: The response returned by the report's download URL. + """ + super().__init__(response.text) + self._response = response + + @property + def response(self) -> Response: + """The response returned by the download URL.""" + return self._response -class TargetProcessingTimeout(Exception): + +@beartype +class RecoCountsReportDownloadError(Exception): + """Exception raised when downloading a reco counts report fails. + + This is raised, for example, when the report's URL has expired. """ - Exception raised when waiting for a target to be processed times out. + + def __init__(self, response: Response) -> None: + """ + Args: + response: The response returned by the report's download URL. + """ + super().__init__(response.text) + self._response = response + + @property + def response(self) -> Response: + """The response returned by the download URL.""" + return self._response + + +@beartype +class RecoCountsReportTimeoutError(Exception): + """Exception raised when waiting for a reco counts report to be + generated times out. """ + + +@beartype +class ServerError(Exception): # pragma: no cover + """Exception raised when VWS returns a server error.""" + + def __init__(self, response: Response) -> None: + """ + Args: + response: The response returned by Vuforia. + """ + super().__init__(response.text) + self._response = response + + @property + def response(self) -> Response: + """The response returned by Vuforia which included this error.""" + return self._response diff --git a/src/vws/exceptions/model_target_exceptions.py b/src/vws/exceptions/model_target_exceptions.py new file mode 100644 index 000000000..8eaf09878 --- /dev/null +++ b/src/vws/exceptions/model_target_exceptions.py @@ -0,0 +1,210 @@ +"""Exceptions raised by the Vuforia Model Target Web API. + +See +https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/. +""" + +import json +from typing import Any + +from beartype import beartype + +from vws.reports import ModelTargetGenerationDetail +from vws.response import Response # noqa: TC001 + + +@beartype +def _is_json_object(*, value: object) -> bool: + """Get whether a decoded JSON value is an object. + + Args: + value: A decoded JSON value. + + Returns: + Whether the value is a JSON object. + """ + return isinstance(value, dict) + + +@beartype +def _json_object(*, value: str) -> dict[str, Any]: + """Get a JSON object from a string. + + Args: + value: A string which may be a JSON object. + + Returns: + The JSON object, or an empty dictionary if the string is not a + JSON object. + """ + try: + loaded: Any = json.loads(s=value) + except json.JSONDecodeError: + return {} + + if not _is_json_object(value=loaded): + return {} + + json_object: dict[str, Any] = loaded + return json_object + + +@beartype +def _error_dict(*, response: Response) -> dict[str, Any]: + """Get the error object of a Model Target Web API error response. + + Args: + response: The response returned by Vuforia. + + Returns: + The error object, or an empty dictionary if the response has no + error object. Some errors, such as those given by the load + balancer in front of Vuforia, are not shaped like Model Target + Web API errors. + """ + body = _json_object(value=response.text) + if "error" not in body: + return {} + + error: Any = body["error"] + if not _is_json_object(value=error): + return {} + + error_dict: dict[str, Any] = error + return error_dict + + +@beartype +class ModelTargetError(Exception): + """Base class for Vuforia Model Target Web API exceptions.""" + + def __init__(self, response: Response) -> None: + """ + Args: + response: The response to a request to Vuforia. + """ + super().__init__(response.text) + self._response = response + + @property + def response(self) -> Response: + """The response returned by Vuforia which included this error.""" + return self._response + + @property + def code(self) -> str: + """The error code given by Vuforia, or an empty string.""" + error = _error_dict(response=self._response) + return str(object=error["code"]) if "code" in error else "" + + @property + def message(self) -> str: + """The error message given by Vuforia, or an empty string.""" + error = _error_dict(response=self._response) + return str(object=error["message"]) if "message" in error else "" + + @property + def target(self) -> str: + """The error target given by Vuforia, or an empty string.""" + error = _error_dict(response=self._response) + return str(object=error["target"]) if "target" in error else "" + + @property + def details(self) -> list[ModelTargetGenerationDetail]: + """The error details given by Vuforia. + + Vuforia gives one detail per validation problem it found with a + dataset creation request. + """ + error = _error_dict(response=self._response) + if "details" not in error: + return [] + + return [ + ModelTargetGenerationDetail( + code=detail["code"], + message=detail["message"], + ) + for detail in error["details"] + ] + + +@beartype +class ModelTargetAuthenticationError(ModelTargetError): + """Exception raised when a Model Target Web API request is not + authenticated. + + For example, the bearer token may be missing, malformed or expired. + """ + + +@beartype +class ModelTargetValidationError(ModelTargetError): + """Exception raised when Vuforia rejects a Model Target dataset + creation + request. + + See :attr:`~.ModelTargetError.details` for the problems which Vuforia + found. + """ + + +@beartype +class UnknownModelTargetDatasetError(ModelTargetError): + """Exception raised when no Model Target dataset matches a given UUID. + + Standard and advanced datasets are separate resources, so this is also + raised when the given UUID matches a dataset of the other type. + """ + + +@beartype +class ModelTargetDatasetNotDoneError(ModelTargetError): + """Exception raised when a Model Target dataset is downloaded before + Vuforia has generated it. + """ + + +@beartype +class ModelTargetOAuth2Error(Exception): + """Exception raised when Vuforia does not give an access token. + + For example, the given client ID and client secret may not match a set + of Model Target Web API credentials. + """ + + def __init__(self, response: Response) -> None: + """ + Args: + response: The response to a request to Vuforia's token + endpoint. + """ + super().__init__(response.text) + self._response = response + + @property + def response(self) -> Response: + """The response returned by Vuforia which included this error.""" + return self._response + + @property + def error(self) -> str: + """The OAuth2 error code, or an empty string.""" + body = _json_object(value=self._response.text) + return str(object=body["error"]) if "error" in body else "" + + @property + def error_description(self) -> str: + """The OAuth2 error description, or an empty string.""" + body = _json_object(value=self._response.text) + if "error_description" not in body: + return "" + + return str(object=body["error_description"]) + + +@beartype +class ModelTargetDatasetTimeoutError(Exception): + """Exception raised when waiting for a Model Target dataset to be + generated times out. + """ diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index cbb95119c..216677f15 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -1,166 +1,256 @@ """ -Exception raised when Vuforia returns a response with a result code matching +Exception raised when Vuforia returns a response with a result code +matching one of those documented at -https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Interperete-VWS-API-Result-Codes. +https://developer.vuforia.com/library/web-api/cloud-targets-web-services- +api#result-codes. """ import json from urllib.parse import urlparse -from vws.exceptions.base_exceptions import VWSException +from beartype import beartype +from vws.exceptions.base_exceptions import VWSError -class UnknownTarget(VWSException): + +def _target_id_from_url(*, url: str) -> str: + """Return the target ID from a VWS response URL. + + Paths may include a custom base URL prefix. The target ID is the + path segment after ``targets``, ``summary``, or ``duplicates``. """ - Exception raised when Vuforia returns a response with a result code + path = urlparse(url=url).path + parts = [part for part in path.split(sep="/") if part] + for marker in ("targets", "summary", "duplicates"): + try: + marker_index = parts.index(marker) + except ValueError: + continue + return parts[marker_index + 1] + message = f"Could not find a target ID in URL path {path!r}" + raise ValueError(message) + + +@beartype +class UnknownTargetError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'UnknownTarget'. """ @property def target_id(self) -> str: - """ - The unknown target ID. - """ - path = urlparse(self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep='/', maxsplit=2)[-1] + """The unknown target ID.""" + return _target_id_from_url(url=self.response.url) -class Fail(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class FailError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'Fail'. """ -class BadImage(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class BadImageError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'BadImage'. """ -class AuthenticationFailure(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class AuthenticationFailureError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. """ -# See https://github.com/VWS-Python/vws-python/issues/822. -class RequestQuotaReached(VWSException): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class RequestQuotaReachedError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'RequestQuotaReached'. """ -class TargetStatusProcessing(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class TargetStatusProcessingError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'TargetStatusProcessing'. """ @property def target_id(self) -> str: - """ - The processing target ID. - """ - path = urlparse(self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep='/', maxsplit=2)[-1] + """The processing target ID.""" + return _target_id_from_url(url=self.response.url) # This is not simulated by the mock. -class DateRangeError(VWSException): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class DateRangeError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code 'DateRangeError'. """ -# This is not simulated by the mock. -class TargetQuotaReached(VWSException): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class TargetQuotaReachedError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'TargetQuotaReached'. """ -# This is not simulated by the mock. -class ProjectSuspended(VWSException): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class ProjectSuspendedError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'ProjectSuspended'. """ -# This is not simulated by the mock. -class ProjectHasNoAPIAccess(VWSException): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code - 'ProjectHasNoAPIAccess'. +@beartype +class ProjectHasNoAPIAccessError(VWSError): + """Exception raised when Vuforia returns a response with a result code + 'ProjectHasNoApiAccess'. """ -class ProjectInactive(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class ProjectInactiveError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'ProjectInactive'. """ -class MetadataTooLarge(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class MetadataTooLargeError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'MetadataTooLarge'. """ -class RequestTimeTooSkewed(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class RequestTimeTooSkewedError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'RequestTimeTooSkewed'. """ -class TargetNameExist(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class TargetNameExistError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'TargetNameExist'. """ @property def target_name(self) -> str: - """ - The target name which already exists. - """ - response_body = self.response.request.body or b'' - request_json = json.loads(response_body) - return str(request_json['name']) + """The target name which already exists.""" + response_body = self.response.request_body or b"" + request_json = json.loads(s=response_body) + return str(object=request_json["name"]) -class ImageTooLarge(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class ImageTooLargeError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'ImageTooLarge'. """ -class TargetStatusNotSuccess(VWSException): - """ - Exception raised when Vuforia returns a response with a result code +@beartype +class TargetStatusNotSuccessError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'TargetStatusNotSuccess'. """ @property def target_id(self) -> str: - """ - The unknown target ID. - """ - path = urlparse(self.response.url).path - # Every HTTP path which can raise this error is in the format - # `/something/{target_id}`. - return path.split(sep='/', maxsplit=2)[-1] + """The unknown target ID.""" + return _target_id_from_url(url=self.response.url) + + +@beartype +class TooManyRequestsError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code + 'TooManyRequests'. + """ + + +# This is not simulated by client code because the accept parameter uses +# the VuMarkAccept enum, which only allows valid values. +@beartype +class InvalidAcceptHeaderError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code + ``InvalidAcceptHeader``. + """ + + +@beartype +class InvalidInstanceIdError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``InvalidInstanceId``. + """ + + +# This is not simulated by client code because the request body +# is always valid JSON when using this client. +@beartype +class BadRequestError(VWSError): # pragma: no cover + """Exception raised when Vuforia returns a response with a result code + ``BadRequest``. + """ + + +@beartype +class InvalidTargetTypeError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``InvalidTargetType``. + """ + + +@beartype +class QuotaExceededError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``QuotaExceeded``. + """ + + +@beartype +class LicenseCheckFailedError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``LicenseCheckFailed``. + """ + + +@beartype +class AuthorizationFailedError(VWSError): + """Exception raised when Vuforia returns a response with a result code + ``AuthorizationFailed``. + """ + + +VWSError.register_exceptions_by_result_code( + exceptions_by_result_code={ + "AuthenticationFailure": AuthenticationFailureError, + "AuthorizationFailed": AuthorizationFailedError, + "BadImage": BadImageError, + "BadRequest": BadRequestError, + "DateRangeError": DateRangeError, + "Fail": FailError, + "ImageTooLarge": ImageTooLargeError, + "InvalidAcceptHeader": InvalidAcceptHeaderError, + "InvalidInstanceId": InvalidInstanceIdError, + "InvalidTargetType": InvalidTargetTypeError, + "LicenseCheckFailed": LicenseCheckFailedError, + "MetadataTooLarge": MetadataTooLargeError, + "ProjectHasNoApiAccess": ProjectHasNoAPIAccessError, + "ProjectInactive": ProjectInactiveError, + "ProjectSuspended": ProjectSuspendedError, + "QuotaExceeded": QuotaExceededError, + "RequestQuotaReached": RequestQuotaReachedError, + "RequestTimeTooSkewed": RequestTimeTooSkewedError, + "TargetNameExist": TargetNameExistError, + "TargetQuotaReached": TargetQuotaReachedError, + "TargetStatusNotSuccess": TargetStatusNotSuccessError, + "TargetStatusProcessing": TargetStatusProcessingError, + "UnknownTarget": UnknownTargetError, + }, +) diff --git a/src/vws/include_target_data.py b/src/vws/include_target_data.py index 1c5afa89a..0692a1e40 100644 --- a/src/vws/include_target_data.py +++ b/src/vws/include_target_data.py @@ -1,16 +1,18 @@ -""" -Tools for managing ``CloudRecoService.query``'s ``include_target_data``. -""" +"""Tools for managing ``CloudRecoService.query``'s ``include_target_data``.""" -from enum import Enum +from enum import StrEnum, auto, unique +from beartype import beartype -class CloudRecoIncludeTargetData(Enum): + +@beartype +@unique +class CloudRecoIncludeTargetData(StrEnum): """ Options for the ``include_target_data`` parameter of ``CloudRecoService.query``. """ - TOP = 'top' - NONE = 'none' - ALL = 'all' + TOP = auto() + NONE = auto() + ALL = auto() diff --git a/src/vws/model_target_datasets.py b/src/vws/model_target_datasets.py new file mode 100644 index 000000000..95a9eaa18 --- /dev/null +++ b/src/vws/model_target_datasets.py @@ -0,0 +1,152 @@ +"""Structures for describing Model Target datasets to create. + +See +https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/. +""" + +from collections.abc import Sequence # noqa: TC003 +from dataclasses import dataclass +from enum import StrEnum, unique + +from beartype import BeartypeConf, beartype + + +@beartype +@unique +class ModelTargetDatasetType(StrEnum): + """The kinds of Model Target dataset which Vuforia generates. + + Standard and advanced datasets are separate resources, so a dataset + created as one type is not visible to requests for the other type. + """ + + STANDARD = "standard" + ADVANCED = "advanced" + + +@beartype +@unique +class AutomaticColoring(StrEnum): + """Options for a model's ``automaticColoring``.""" + + ALWAYS = "always" + AUTO = "auto" + NEVER = "never" + + +@beartype +@unique +class CadDataFormat(StrEnum): + """Options for a model's ``cadDataFormat``.""" + + DAE = "DAE" + FBX = "FBX" + GLB = "GLB" + IGES = "IGES" + OBJ = "OBJ" + PVZ = "PVZ" + STL = "STL" + VRML = "VRML" + ZIP = "ZIP" + + +@beartype +@unique +class MotionHint(StrEnum): + """Options for a model's ``motionHint``.""" + + ADAPTIVE = "adaptive" + DYNAMIC = "dynamic" + STATIC = "static" + + +@beartype +@unique +class OptimizeTrackingFor(StrEnum): + """Options for a model's ``optimizeTrackingFor``.""" + + AR_CONTROLLER = "ar_controller" + DEFAULT = "default" + LOW_FEATURE_OBJECTS = "low_feature_objects" + + +@beartype +@unique +class RealisticAppearance(StrEnum): + """Options for a model's ``realisticAppearance``. + + This is documented for advanced datasets only. + """ + + AUTO = "auto" + FALSE = "false" + TRUE = "true" + + +@beartype +@unique +class Simplify(StrEnum): + """Options for a model's ``simplify``.""" + + ALWAYS = "always" + AUTO = "auto" + NEVER = "never" + + +@beartype +@unique +class TrackingMode(StrEnum): + """Options for a model's ``trackingMode``.""" + + CAR = "car" + DEFAULT = "default" + SCAN = "scan" + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, kw_only=True) +class GuideViewPosition: + """The position of a guide view.""" + + rotation: Sequence[float] + translation: Sequence[float] + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetView: + """A guide view of a model.""" + + name: str + guide_view_position: GuideViewPosition + states: Sequence[str] | None = None + """The State-Based Model Target states which this view applies to. + + Every given state must be named by the model's + ``state_based_configuration_json_string``. + """ + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetModel: + """A model to generate a Model Target dataset from. + + One and only one of ``cad_data_url`` and ``cad_data_blob`` is + required. + """ + + name: str + cad_data_url: str | None = None + cad_data_blob: str | None = None + automatic_coloring: AutomaticColoring | None = None + cad_data_format: CadDataFormat | None = None + motion_hint: MotionHint | None = None + optimize_tracking_for: OptimizeTrackingFor | None = None + realistic_appearance: RealisticAppearance | None = None + """This is documented for advanced datasets only.""" + + simplify: Simplify | None = None + tracking_mode: TrackingMode | None = None + state_based_configuration_json_string: str | None = None + views: Sequence[ModelTargetView] | None = None diff --git a/src/vws/model_target_service.py b/src/vws/model_target_service.py new file mode 100644 index 000000000..70b116d0a --- /dev/null +++ b/src/vws/model_target_service.py @@ -0,0 +1,366 @@ +"""Interface to the Vuforia Model Target Web API.""" + +import time +from collections.abc import Sequence # noqa: TC003 +from http import HTTPMethod + +from beartype import BeartypeConf, beartype + +from vws._model_targets import ( + JSON_CONTENT_TYPE, + OAUTH2_TOKEN_BODY, + OAUTH2_TOKEN_PATH, + access_token_from_response, + dataset_collection_path, + dataset_download_path, + dataset_path, + dataset_request_body, + dataset_status_path, + dataset_uuid_from_response, + oauth2_token_headers, + raise_for_error, + status_report_from_response, +) +from vws.exceptions.model_target_exceptions import ( + ModelTargetDatasetTimeoutError, +) +from vws.model_target_datasets import ( # noqa: TC001 + ModelTargetDatasetType, + ModelTargetModel, +) +from vws.reports import ( + ModelTargetDatasetStatuses, + ModelTargetDatasetStatusReport, +) +from vws.response import Response # noqa: TC001 +from vws.transports import RequestsTransport, Transport + +_TOKEN_EXPIRY_MARGIN_SECONDS = 60.0 + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class ModelTargetService: + """An interface to the Vuforia Model Target Web API.""" + + def __init__( + self, + *, + client_id: str, + client_secret: str, + base_vws_url: str = "https://vws.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: Transport | None = None, + ) -> None: + """ + Args: + client_id: A Model Target Web API OAuth2 client + ID. + client_secret: A Model Target Web API OAuth2 + client secret. + base_vws_url: The base URL for the VWS API, which + also serves the Model Target Web API. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The HTTP transport to use for + requests. Defaults to + ``RequestsTransport()``. + """ + self._client_id = client_id + self._client_secret = client_secret + self._base_vws_url = base_vws_url + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else RequestsTransport() + ) + self._access_token: str | None = None + self._access_token_expiry_time = 0.0 + + def get_access_token(self) -> str: + """Get an OAuth2 access token for the Model Target Web API. + + A token is requested only when the client has no token which is + still valid, so this can be called before each request. + + Returns: + A bearer token. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. For example, the + given client ID and client secret may not match a set of + Model Target Web API credentials. + """ + request_time = time.monotonic() + if ( + self._access_token is not None + and request_time < self._access_token_expiry_time + ): + return self._access_token + + response = self._transport( + method=HTTPMethod.POST, + url=self._base_vws_url.rstrip("/") + OAUTH2_TOKEN_PATH, + headers=oauth2_token_headers( + client_id=self._client_id, + client_secret=self._client_secret, + ), + data=OAUTH2_TOKEN_BODY, + request_timeout=self._request_timeout_seconds, + ) + + access_token, expires_in_seconds = access_token_from_response( + response=response, + ) + self._access_token = access_token + self._access_token_expiry_time = ( + request_time + expires_in_seconds - _TOKEN_EXPIRY_MARGIN_SECONDS + ) + return access_token + + def make_request( + self, + *, + method: str, + data: bytes, + request_path: str, + extra_headers: dict[str, str] | None = None, + ) -> Response: + """Make an authenticated request to the Model Target Web API. + + Args: + method: The HTTP method which will be used in + the request. + data: The request body which will be used in the + request. + request_path: The path to the endpoint which + will be used in the request. + extra_headers: Additional headers to include in + the request. + + Returns: + The response to the request. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetError: + Vuforia returned an error. + ~vws.exceptions.custom_exceptions.ServerError: + There is an error with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: + Vuforia is rate limiting access. + """ + headers = { + "Authorization": f"Bearer {self.get_access_token()}", + **(extra_headers or {}), + } + + response = self._transport( + method=method, + url=self._base_vws_url.rstrip("/") + request_path, + headers=headers, + data=data, + request_timeout=self._request_timeout_seconds, + ) + + raise_for_error(response=response) + return response + + def create_dataset( + self, + *, + name: str, + target_sdk: str, + models: Sequence[ModelTargetModel], + dataset_type: ModelTargetDatasetType, + ) -> str: + """Start generating a Model Target dataset. + + Vuforia generates the dataset in the background, so it is not + available to download immediately. Use + :meth:`wait_for_dataset_generated` to wait for it. + + Args: + name: The name of the dataset. + target_sdk: The Vuforia Engine version to generate the dataset + for. + models: The models to generate the dataset from. A standard + dataset takes exactly one model. + dataset_type: Whether to create a standard or an advanced + dataset. + + Returns: + The UUID of the new dataset. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.ModelTargetValidationError: + Vuforia rejected the request. For example, a model may + give neither a CAD data URL nor a CAD data blob. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + response = self.make_request( + method=HTTPMethod.POST, + data=dataset_request_body( + name=name, + target_sdk=target_sdk, + models=models, + ), + request_path=dataset_collection_path(dataset_type=dataset_type), + extra_headers={"Content-Type": JSON_CONTENT_TYPE}, + ) + + return dataset_uuid_from_response(response=response) + + def get_dataset_status( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + ) -> ModelTargetDatasetStatusReport: + """Get the status of a Model Target dataset. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to get the status of. + + Returns: + The status of the dataset. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + response = self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=dataset_status_path( + dataset_type=dataset_type, + dataset_uuid=dataset_uuid, + ), + ) + + return status_report_from_response(response=response) + + def wait_for_dataset_generated( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + seconds_between_requests: float = 0.2, + timeout_seconds: float = 60 * 5, + ) -> ModelTargetDatasetStatusReport: + """Wait for Vuforia to finish generating a Model Target dataset. + + A dataset which failed to generate is also finished, so the + returned report may have a + :attr:`~.ModelTargetDatasetStatusReport.status` of + ``FAILED``. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to wait for. + seconds_between_requests: The number of seconds to wait between + requests made while polling the dataset's status. + timeout_seconds: The maximum number of seconds to wait for the + dataset to be generated. + + Returns: + The status of the dataset once it is no longer processing. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetDatasetTimeoutError: + The dataset was not generated within ``timeout_seconds`` + seconds. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + """ + start_time = time.monotonic() + while True: + report = self.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + if report.status != ModelTargetDatasetStatuses.PROCESSING: + return report + + elapsed_time = time.monotonic() - start_time + if elapsed_time > timeout_seconds: + raise ModelTargetDatasetTimeoutError + + time.sleep(seconds_between_requests) + + def download_dataset( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + ) -> bytes: + """Download a generated Model Target dataset. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to download. + + Returns: + The dataset, as the bytes of a zip file. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + ~vws.exceptions.model_target_exceptions.ModelTargetDatasetNotDoneError: + Vuforia has not generated the dataset. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + response = self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=dataset_download_path( + dataset_type=dataset_type, + dataset_uuid=dataset_uuid, + ), + ) + + return response.content + + def delete_dataset( + self, + *, + dataset_uuid: str, + dataset_type: ModelTargetDatasetType, + ) -> None: + """Delete a Model Target dataset. + + Args: + dataset_uuid: The UUID of the dataset, as given by + :meth:`create_dataset`. + dataset_type: The kind of dataset to delete. + + Raises: + ~vws.exceptions.model_target_exceptions.ModelTargetAuthenticationError: + The request was not authenticated. + ~vws.exceptions.model_target_exceptions.UnknownModelTargetDatasetError: + No dataset of the given type matches the given UUID. + ~vws.exceptions.model_target_exceptions.ModelTargetOAuth2Error: + Vuforia did not give an access token. + """ + self.make_request( + method=HTTPMethod.DELETE, + data=b"", + request_path=dataset_path( + dataset_type=dataset_type, + dataset_uuid=dataset_uuid, + ), + ) diff --git a/src/vws/query.py b/src/vws/query.py index 5e456f5aa..3f69261ef 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -1,65 +1,80 @@ -""" -Tools for interacting with the Vuforia Cloud Recognition Web APIs. -""" +"""Tools for interacting with the Vuforia Cloud Recognition Web APIs.""" -import datetime -import io -from typing import List, Optional -from urllib.parse import urljoin +import json +from http import HTTPMethod, HTTPStatus +from typing import Any -import requests +from beartype import BeartypeConf, beartype from urllib3.filepost import encode_multipart_formdata from vws_auth_tools import authorization_header, rfc_1123_date +from vws._image_utils import ImageType as _ImageType +from vws._image_utils import get_image_data as _get_image_data +from vws.exceptions.base_exceptions import CloudRecoError from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailure, - BadImage, - InactiveProject, - MatchProcessing, - MaxNumResultsOutOfRange, - RequestTimeTooSkewed, + AuthenticationFailureError, + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, + RequestTimeTooSkewedError, ) from vws.exceptions.custom_exceptions import ( - ConnectionErrorPossiblyImageTooLarge, + RequestEntityTooLargeError, + ServerError, ) from vws.include_target_data import CloudRecoIncludeTargetData -from vws.reports import QueryResult, TargetData +from vws.reports import QueryResult +from vws.transports import RequestsTransport, Transport +@beartype(conf=BeartypeConf(is_pep484_tower=True)) class CloudRecoService: - """ - An interface to the Vuforia Cloud Recognition Web APIs. - """ + """An interface to the Vuforia Cloud Recognition Web APIs.""" def __init__( self, + *, client_access_key: str, client_secret_key: str, - base_vwq_url: str = 'https://cloudreco.vuforia.com', + base_vwq_url: str = "https://cloudreco.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: Transport | None = None, ) -> None: """ Args: client_access_key: A VWS client access key. client_secret_key: A VWS client secret key. base_vwq_url: The base URL for the VWQ API. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The HTTP transport to use for + requests. Defaults to + ``RequestsTransport()``. """ self._client_access_key = client_access_key self._client_secret_key = client_secret_key self._base_vwq_url = base_vwq_url + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else RequestsTransport() + ) def query( self, - image: io.BytesIO, + *, + image: _ImageType, max_num_results: int = 1, include_target_data: CloudRecoIncludeTargetData = ( CloudRecoIncludeTargetData.TOP ), - ) -> List[QueryResult]: - """ - Use the Vuforia Web Query API to make an Image Recognition Query. + ) -> list[QueryResult]: + """Use the Vuforia Web Query API to make an Image Recognition + Query. See - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query + https://developer.vuforia.com/library/web-api/vuforia-query-web-api for parameter details. Args: @@ -72,40 +87,43 @@ def query( none (return no target_data), all (for all matched targets). Raises: - ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailure: The - client access key pair is not correct. - ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRange: + ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError: + The client access key pair is not correct. + ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError: ``max_num_results`` is not within the range (1, 50). - ~vws.exceptions.cloud_reco_exceptions.MatchProcessing: The given - image matches a target which was recently added, updated or - deleted and Vuforia returns an error in this case. - ~vws.exceptions.cloud_reco_exceptions.InactiveProject: The project - is inactive. - ~vws.exceptions.custom_exceptions.ConnectionErrorPossiblyImageTooLarge: - The given image is too large. - ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewed: There - is an error with the time sent to Vuforia. - ~vws.exceptions.cloud_reco_exceptions.BadImage: There is a problem - with the given image. For example, it must be a JPEG or PNG - file in the grayscale or RGB color space. + ~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The + project is inactive. + ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError: + There is an error with the time sent to Vuforia. + ~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a + problem with the given image. For example, it must be a JPEG or + PNG file in the grayscale or RGB color space. + ~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The + given image is too large. + ~vws.exceptions.custom_exceptions.ServerError: There is an + error with Vuforia's servers. + ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned + a client error without a recognized JSON body. + json.JSONDecodeError: Vuforia returned a successful response with + an invalid JSON body. Returns: An ordered list of target details of matching targets. """ - image_content = image.getvalue() - body = { - 'image': ('image.jpeg', image_content, 'image/jpeg'), - 'max_num_results': (None, int(max_num_results), 'text/plain'), - 'include_target_data': ( + image_content = _get_image_data(image=image) + body: dict[str, Any] = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, int(max_num_results), "text/plain"), + "include_target_data": ( None, include_target_data.value, - 'text/plain', + "text/plain", ), } date = rfc_1123_date() - request_path = '/v1/query' - content, content_type_header = encode_multipart_formdata(body) - method = 'POST' + request_path = "/v1/query" + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST authorization_string = authorization_header( access_key=self._client_access_key, @@ -113,67 +131,64 @@ def query( method=method, content=content, # Note that this is not the actual Content-Type header value sent. - content_type='multipart/form-data', + content_type="multipart/form-data", date=date, request_path=request_path, ) headers = { - 'Authorization': authorization_string, - 'Date': date, - 'Content-Type': content_type_header, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, } + response = self._transport( + method=method, + url=self._base_vwq_url.rstrip("/") + request_path, + headers=headers, + data=content, + request_timeout=self._request_timeout_seconds, + ) + + if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: + raise RequestEntityTooLargeError(response=response) + + if "Integer out of range" in response.text: + raise MaxNumResultsOutOfRangeError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + content_type = { + key.lower(): value for key, value in response.headers.items() + }.get("content-type", "") + if ( + response.status_code >= HTTPStatus.BAD_REQUEST + and not content_type.lower().startswith("application/json") + ): + raise CloudRecoError(response=response) + try: - response = requests.request( - method=method, - url=urljoin(base=self._base_vwq_url, url=request_path), - headers=headers, - data=content, - ) - except requests.exceptions.ConnectionError as exc: - raise ConnectionErrorPossiblyImageTooLarge( - request=exc.request, - response=exc.response, - ) from exc - - if 'Integer out of range' in response.text: - raise MaxNumResultsOutOfRange(response=response) - - if 'No content to map due to end-of-input' in response.text: - raise MatchProcessing(response=response) - - result_code = response.json()['result_code'] - if result_code != 'Success': + response_body = json.loads(s=response.text) + except json.JSONDecodeError as exc: + if response.status_code >= HTTPStatus.BAD_REQUEST: + raise CloudRecoError(response=response) from exc + raise + + result_code = response_body["result_code"] + if result_code != "Success": exception = { - 'AuthenticationFailure': AuthenticationFailure, - 'BadImage': BadImage, - 'InactiveProject': InactiveProject, - 'RequestTimeTooSkewed': RequestTimeTooSkewed, + "AuthenticationFailure": AuthenticationFailureError, + "BadImage": BadImageError, + "InactiveProject": InactiveProjectError, + "RequestTimeTooSkewed": RequestTimeTooSkewedError, }[result_code] raise exception(response=response) - result = [] - result_list = list(response.json()['results']) - for item in result_list: - target_data: Optional[TargetData] = None - if 'target_data' in item: - target_data_dict = item['target_data'] - metadata = target_data_dict['application_metadata'] - timestamp_string = target_data_dict['target_timestamp'] - target_timestamp = datetime.datetime.utcfromtimestamp( - timestamp_string, - ) - target_data = TargetData( - name=target_data_dict['name'], - application_metadata=metadata, - target_timestamp=target_timestamp, - ) - - query_result = QueryResult( - target_id=item['target_id'], - target_data=target_data, - ) - - result.append(query_result) - return result + result_list = list(response_body["results"]) + return [ + QueryResult.from_response_dict(response_dict=item) + for item in result_list + ] diff --git a/src/vws/reports.py b/src/vws/reports.py index fd757fa9c..2be447b03 100644 --- a/src/vws/reports.py +++ b/src/vws/reports.py @@ -1,20 +1,23 @@ -""" -Classes for representing Vuforia reports. -""" +"""Classes for representing Vuforia reports.""" +import csv import datetime +import io +from collections.abc import Sequence # noqa: TC003 from dataclasses import dataclass -from enum import Enum -from typing import Optional +from enum import Enum, unique +from typing import Any, Self +from beartype import BeartypeConf, beartype -@dataclass + +@beartype +@dataclass(frozen=True, kw_only=True) class DatabaseSummaryReport: - """ - A database summary report. + """A database summary report. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Get-a-Database-Summary-Report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. """ active_images: int @@ -30,26 +33,46 @@ class DatabaseSummaryReport: target_quota: int total_recos: int + @classmethod + def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: + """Construct from a VWS API response dict.""" + return cls( + active_images=int(response_dict["active_images"]), + current_month_recos=int(response_dict["current_month_recos"]), + failed_images=int(response_dict["failed_images"]), + inactive_images=int(response_dict["inactive_images"]), + name=response_dict["name"], + previous_month_recos=int(response_dict["previous_month_recos"]), + processing_images=int(response_dict["processing_images"]), + reco_threshold=int(response_dict["reco_threshold"]), + request_quota=int(response_dict["request_quota"]), + request_usage=int(response_dict["request_usage"]), + target_quota=int(response_dict["target_quota"]), + total_recos=int(response_dict["total_recos"]), + ) + +@beartype +@unique class TargetStatuses(Enum): - """ - Constants representing VWS target statuses. + """Constants representing VWS target statuses. + See the 'status' field in - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Retrieve-a-Target-Record + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record """ - PROCESSING = 'processing' - SUCCESS = 'success' - FAILED = 'failed' + PROCESSING = "processing" + SUCCESS = "success" + FAILED = "failed" -@dataclass +@beartype +@dataclass(frozen=True, kw_only=True) class TargetSummaryReport: - """ - A target summary report. + """A target summary report. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Retrieve-a-Target-Summary-Report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. """ status: TargetStatuses @@ -62,14 +85,31 @@ class TargetSummaryReport: current_month_recos: int previous_month_recos: int + @classmethod + def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: + """Construct from a VWS API response dict.""" + return cls( + status=TargetStatuses(value=response_dict["status"]), + database_name=response_dict["database_name"], + target_name=response_dict["target_name"], + upload_date=datetime.date.fromisoformat( + response_dict["upload_date"] + ), + active_flag=bool(response_dict["active_flag"]), + tracking_rating=int(response_dict["tracking_rating"]), + total_recos=int(response_dict["total_recos"]), + current_month_recos=int(response_dict["current_month_recos"]), + previous_month_recos=int(response_dict["previous_month_recos"]), + ) + -@dataclass +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True, kw_only=True) class TargetRecord: - """ - A target record. + """A target record. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Retrieve-a-Target-Record. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. """ target_id: str @@ -80,38 +120,272 @@ class TargetRecord: reco_rating: str -@dataclass +@beartype +@dataclass(frozen=True, kw_only=True) class TargetData: - """ - The target data optionally included with a query match. - """ + """The target data optionally included with a query match.""" name: str - application_metadata: Optional[str] + application_metadata: str | None target_timestamp: datetime.datetime -@dataclass +@beartype +@dataclass(frozen=True, kw_only=True) class QueryResult: - """ - One query match result. + """One query match result. See - https://library.vuforia.com/articles/Solution/How-To-Perform-an-Image-Recognition-Query. + https://developer.vuforia.com/library/web-api/vuforia-query-web-api. """ target_id: str - target_data: Optional[TargetData] + target_data: TargetData | None + + @classmethod + def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: + """Construct from a VWS API query result item dict.""" + target_data: TargetData | None = None + if "target_data" in response_dict: + target_data_dict = response_dict["target_data"] + target_timestamp = datetime.datetime.fromtimestamp( + timestamp=target_data_dict["target_timestamp"], + tz=datetime.UTC, + ) + target_data = TargetData( + name=target_data_dict["name"], + application_metadata=target_data_dict["application_metadata"], + target_timestamp=target_timestamp, + ) + return cls( + target_id=response_dict["target_id"], + target_data=target_data, + ) -@dataclass +@beartype +@dataclass(frozen=True, kw_only=True) class TargetStatusAndRecord: - """ - The target status and a target record. + """The target status and a target record. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Retrieve-a-Target-Record. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. """ status: TargetStatuses target_record: TargetRecord + + @classmethod + def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: + """Construct from a VWS API response dict.""" + status = TargetStatuses(value=response_dict["status"]) + target_record_dict = dict(response_dict["target_record"]) + target_record = TargetRecord( + target_id=target_record_dict["target_id"], + active_flag=bool(target_record_dict["active_flag"]), + name=target_record_dict["name"], + width=float(target_record_dict["width"]), + tracking_rating=int(target_record_dict["tracking_rating"]), + reco_rating=target_record_dict["reco_rating"], + ) + return cls(status=status, target_record=target_record) + + +@beartype +@dataclass(frozen=True, kw_only=True) +class RecoCountsReportRequest: + """A requested database reco counts report. + + See + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. + """ + + transaction_id: str + presigned_url: str + """The URL to download the report from. + + Real Vuforia's URLs expire just under seven days after the report is + requested. + """ + + @classmethod + def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: + """Construct from a VWS API response dict.""" + return cls( + transaction_id=response_dict["transaction_id"], + presigned_url=response_dict["presigned_url"], + ) + + +@beartype +@unique +class ModelTargetDatasetStatuses(Enum): + """Constants representing Model Target dataset generation statuses. + + See the 'status' field of the dataset status response at + https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/. + """ + + PROCESSING = "processing" + DONE = "done" + FAILED = "failed" + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetGenerationDetail: + """One detail of a Model Target dataset generation warning.""" + + code: str + message: str + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetGenerationError: + """The reason a Model Target dataset failed to generate.""" + + code: str + message: str + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetGenerationWarning: + """A warning about a generated Model Target dataset. + + A dataset with a warning is generated, and can be downloaded. + """ + + code: str + message: str + target: str + details: Sequence[ModelTargetGenerationDetail] + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetDatasetStatusReport: + """The status of a Model Target dataset. + + See + https://developer.vuforia.com/library/vuforia-engine/web-api/model-target-web-api/. + """ + + status: ModelTargetDatasetStatuses + dataset_uuid: str + created_at: datetime.datetime + eta: datetime.datetime | None + """When Vuforia expects to finish generating the dataset. + + This is given only while the dataset is processing. + """ + + completed_at: datetime.datetime | None + """When Vuforia finished generating the dataset. + + This is given only once the dataset is no longer processing. + """ + + error: ModelTargetGenerationError | None + """Why the dataset failed to generate. + + This is given only for a failed dataset. + """ + + warning: ModelTargetGenerationWarning | None + """A warning about the generated dataset. + + This is given only for a generated dataset which has a warning. + """ + + @classmethod + def from_response_dict(cls, response_dict: dict[str, Any]) -> Self: + """Construct from a Model Target Web API response dict.""" + error: ModelTargetGenerationError | None = None + if "error" in response_dict: + error_dict = dict(response_dict["error"]) + error = ModelTargetGenerationError( + code=error_dict["code"], + message=error_dict["message"], + ) + + warning: ModelTargetGenerationWarning | None = None + if "warning" in response_dict: + warning_dict = dict(response_dict["warning"]) + warning = ModelTargetGenerationWarning( + code=warning_dict["code"], + message=warning_dict["message"], + target=warning_dict["target"], + details=[ + ModelTargetGenerationDetail( + code=detail["code"], + message=detail["message"], + ) + for detail in warning_dict["details"] + ], + ) + + eta: datetime.datetime | None = None + if "eta" in response_dict: + eta = datetime.datetime.fromisoformat(response_dict["eta"]) + + completed_at: datetime.datetime | None = None + if "completedAt" in response_dict: + completed_at = datetime.datetime.fromisoformat( + response_dict["completedAt"], + ) + + return cls( + status=ModelTargetDatasetStatuses(value=response_dict["status"]), + dataset_uuid=response_dict["uuid"], + created_at=datetime.datetime.fromisoformat( + response_dict["createdAt"], + ), + eta=eta, + completed_at=completed_at, + error=error, + warning=warning, + ) + + +@beartype +@dataclass(frozen=True, kw_only=True) +class RecoCount: + """The number of recognitions of one target in a reco counts + report. + """ + + target_id: str + reco_count: int + + +@beartype +@dataclass(frozen=True, kw_only=True) +class RecoCountsReport: + """A downloaded database reco counts report. + + A report for a month with no recognitions has no ``reco_counts``. + """ + + reco_counts: Sequence[RecoCount] + raw_csv: bytes + """The downloaded CSV, before it was parsed. + + Vuforia does not document the format of the report, so it may include + columns which ``reco_counts`` does not expose. + """ + + @classmethod + def from_csv(cls, csv_bytes: bytes) -> Self: + """Construct from the CSV content of a downloaded report.""" + text = csv_bytes.decode(encoding="utf-8") + reader = csv.DictReader(f=io.StringIO(initial_value=text, newline="")) + reco_counts = [ + RecoCount( + target_id=row["target_id"], + reco_count=int(row["reco_count"]), + ) + for row in reader + ] + return cls(reco_counts=reco_counts, raw_csv=csv_bytes) diff --git a/src/vws/response.py b/src/vws/response.py new file mode 100644 index 000000000..d6456819e --- /dev/null +++ b/src/vws/response.py @@ -0,0 +1,19 @@ +"""Responses for requests to VWS and VWQ.""" + +from dataclasses import dataclass + +from beartype import beartype + + +@dataclass(frozen=True, kw_only=True) +@beartype +class Response: + """A response from a request.""" + + text: str + url: str + status_code: int + headers: dict[str, str] + request_body: bytes | str | None + tell_position: int + content: bytes diff --git a/src/vws/transports.py b/src/vws/transports.py new file mode 100644 index 000000000..e699fc718 --- /dev/null +++ b/src/vws/transports.py @@ -0,0 +1,315 @@ +"""HTTP transport implementations for VWS clients.""" + +from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable + +import httpx +import requests +from beartype import BeartypeConf, beartype + +from vws.response import Response + +if TYPE_CHECKING: + from collections.abc import Awaitable + + +@runtime_checkable +class Transport(Protocol): + """Protocol for HTTP transports used by VWS clients. + + A transport is a callable that makes an HTTP request and + returns a ``Response``. + """ + + def close(self) -> None: + """Close the transport and release resources.""" + ... # pylint: disable=unnecessary-ellipsis + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make an HTTP request. + + Args: + method: The HTTP method (e.g. "GET", "POST"). + url: The full URL to request. + headers: Headers to send with the request. + data: The request body as bytes. + request_timeout: The timeout for the request. A float + sets both the connect and read timeouts. A + (connect, read) tuple sets them individually. + + Returns: + A Response populated from the HTTP response. + """ + ... # pylint: disable=unnecessary-ellipsis + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class RequestsTransport: + """HTTP transport using the ``requests`` library. + + This is the default transport. + """ + + def close(self) -> None: + """Close the transport. + + This is a no-op for ``RequestsTransport`` as it does not + hold persistent connections. + """ + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make an HTTP request using ``requests``. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the requests response. + """ + requests_response = requests.request( + method=method, + url=url, + headers=headers, + data=data, + timeout=request_timeout, + ) + + return Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=bytes(requests_response.content), + ) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class HTTPXTransport: + """HTTP transport using the ``httpx`` library. + + Use this transport for environments where ``httpx`` is + preferred over ``requests``. + A single ``httpx.Client`` is reused across requests + for connection pooling. + """ + + def __init__(self) -> None: + """Create an ``HTTPXTransport``.""" + self._client = httpx.Client() + + def close(self) -> None: + """Close the underlying ``httpx.Client``.""" + self._client.close() + + def __enter__(self) -> Self: + """Enter the context manager.""" + return self + + def __exit__(self, *_args: object) -> None: + """Exit the context manager and close the client.""" + self.close() + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make an HTTP request using ``httpx``. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the httpx response. + """ + match request_timeout: + case tuple() as timeout: + connect_timeout, read_timeout = timeout + httpx_timeout = httpx.Timeout( + connect=connect_timeout, + read=read_timeout, + write=None, + pool=None, + ) + case timeout: + httpx_timeout = httpx.Timeout( + connect=timeout, + read=timeout, + write=None, + pool=None, + ) + + httpx_response = self._client.request( + method=method, + url=url, + headers=headers, + content=data, + timeout=httpx_timeout, + follow_redirects=True, + ) + + content = bytes(httpx_response.content) + request_content = httpx_response.request.content + + return Response( + text=httpx_response.text, + url=str(object=httpx_response.url), + status_code=httpx_response.status_code, + headers=dict(httpx_response.headers), + request_body=bytes(request_content) or None, + tell_position=len(content), + content=content, + ) + + +@runtime_checkable +class AsyncTransport(Protocol): + """Protocol for async HTTP transports used by VWS clients. + + An async transport is a callable that makes an HTTP request + and returns a ``Response``. + """ + + async def aclose(self) -> None: + """Close the transport and release resources.""" + ... # pylint: disable=unnecessary-ellipsis + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Awaitable[Response]: + """Make an async HTTP request. + + Args: + method: The HTTP method (e.g. "GET", "POST"). + url: The full URL to request. + headers: Headers to send with the request. + data: The request body as bytes. + request_timeout: The timeout for the request. A float + sets both the connect and read timeouts. A + (connect, read) tuple sets them individually. + + Returns: + A Response populated from the HTTP response. + """ + ... # pylint: disable=unnecessary-ellipsis + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class AsyncHTTPXTransport: + """Async HTTP transport using the ``httpx`` library. + + This is the default transport for async VWS clients. + A single ``httpx.AsyncClient`` is reused across requests + for connection pooling. + """ + + def __init__(self) -> None: + """Create an ``AsyncHTTPXTransport``.""" + self._client = httpx.AsyncClient() + + async def aclose(self) -> None: + """Close the underlying ``httpx.AsyncClient``.""" + await self._client.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *_args: object) -> None: + """Exit the async context manager and close the client.""" + await self.aclose() + + async def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make an async HTTP request using ``httpx``. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the httpx response. + """ + match request_timeout: + case tuple() as timeout: + connect_timeout, read_timeout = timeout + httpx_timeout = httpx.Timeout( + connect=connect_timeout, + read=read_timeout, + write=None, + pool=None, + ) + case timeout: + httpx_timeout = httpx.Timeout( + connect=timeout, + read=timeout, + write=None, + pool=None, + ) + + httpx_response = await self._client.request( + method=method, + url=url, + headers=headers, + content=data, + timeout=httpx_timeout, + follow_redirects=True, + ) + + content = bytes(httpx_response.content) + request_content = httpx_response.request.content + + return Response( + text=httpx_response.text, + url=str(object=httpx_response.url), + status_code=httpx_response.status_code, + headers=dict(httpx_response.headers), + request_body=bytes(request_content) or None, + tell_position=len(content), + content=content, + ) diff --git a/src/vws/vumark_accept.py b/src/vws/vumark_accept.py new file mode 100644 index 000000000..5dd21ab08 --- /dev/null +++ b/src/vws/vumark_accept.py @@ -0,0 +1,18 @@ +"""Tools for managing ``VWS.generate_vumark_instance``'s ``accept``.""" + +from enum import StrEnum, unique + +from beartype import beartype + + +@beartype +@unique +class VuMarkAccept(StrEnum): + """ + Options for the ``accept`` parameter of + ``VWS.generate_vumark_instance``. + """ + + PNG = "image/png" + SVG = "image/svg+xml" + PDF = "application/pdf" diff --git a/src/vws/vumark_service.py b/src/vws/vumark_service.py new file mode 100644 index 000000000..168e8b6f8 --- /dev/null +++ b/src/vws/vumark_service.py @@ -0,0 +1,138 @@ +"""Interface to the Vuforia VuMark Generation Web API.""" + +import json +from http import HTTPMethod, HTTPStatus + +from beartype import BeartypeConf, beartype + +from vws._vws_request import target_api_request +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.transports import RequestsTransport, Transport +from vws.vumark_accept import VuMarkAccept # noqa: TC001 + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class VuMarkService: + """An interface to the Vuforia VuMark Generation Web API.""" + + def __init__( + self, + *, + server_access_key: str, + server_secret_key: str, + base_vws_url: str = "https://vws.vuforia.com", + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: Transport | None = None, + ) -> None: + """ + Args: + server_access_key: A VWS server access key. + server_secret_key: A VWS server secret key. + base_vws_url: The base URL for the VWS API. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The HTTP transport to use for + requests. Defaults to + ``RequestsTransport()``. + """ + self._server_access_key = server_access_key + self._server_secret_key = server_secret_key + self._base_vws_url = base_vws_url + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else RequestsTransport() + ) + + def generate_vumark_instance( + self, + *, + target_id: str, + instance_id: str, + accept: VuMarkAccept, + ) -> bytes: + """Generate a VuMark instance image. + + See + https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ + for parameter details. + + Args: + target_id: The ID of the VuMark target. + instance_id: The instance ID to encode in the VuMark. + accept: The image format to return. + + Returns: + The VuMark instance image bytes. + + Raises: + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.AuthorizationFailedError: There was + a general authentication problem. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a + known database. + ~vws.exceptions.vws_exceptions.InvalidAcceptHeaderError: The + Accept header value is not supported. + ~vws.exceptions.vws_exceptions.InvalidInstanceIdError: The + instance ID is invalid. For example, it may be empty. + ~vws.exceptions.vws_exceptions.InvalidTargetTypeError: The target + is not a VuMark template target. + ~vws.exceptions.vws_exceptions.LicenseCheckFailedError: The + license state and/or type does not allow this request. + ~vws.exceptions.vws_exceptions.QuotaExceededError: No more + instances can be created for the associated license. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.TargetStatusNotSuccessError: The + target is not in the success state. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + request_path = f"/targets/{target_id}/instances" + content_type = "application/json" + request_data = json.dumps(obj={"instance_id": instance_id}).encode( + encoding="utf-8", + ) + + response = target_api_request( + content_type=content_type, + server_access_key=self._server_access_key, + server_secret_key=self._server_secret_key, + method=HTTPMethod.POST, + data=request_data, + request_path=request_path, + base_vws_url=self._base_vws_url, + request_timeout_seconds=self._request_timeout_seconds, + extra_headers={"Accept": accept}, + transport=self._transport, + ) + + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + if response.status_code == HTTPStatus.OK: + return response.content + + result_code = json.loads(s=response.text)["result_code"] + + raise VWSError.from_result_code( + result_code=result_code, + response=response, + ) diff --git a/src/vws/vws.py b/src/vws/vws.py index 9a4b18b48..2b241ab1d 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -1,212 +1,166 @@ -""" -Tools for interacting with Vuforia APIs. -""" +"""Tools for interacting with Vuforia APIs.""" import base64 -import io +import calendar # noqa: TC003 import json -from datetime import date -from time import sleep -from typing import Dict, List, Optional, Union -from urllib.parse import urljoin +import time +from http import HTTPMethod, HTTPStatus -import requests -from func_timeout import func_set_timeout -from func_timeout.exceptions import FunctionTimedOut -from requests import Response -from vws_auth_tools import authorization_header, rfc_1123_date +from beartype import BeartypeConf, beartype -from vws.exceptions.custom_exceptions import ( - TargetProcessingTimeout, - UnknownVWSErrorPossiblyBadName, +from vws._image_utils import ImageType as _ImageType +from vws._image_utils import get_image_data as _get_image_data +from vws._reco_counts import ( + reco_counts_report_body, + reco_counts_report_path, + report_from_download_response, ) -from vws.exceptions.vws_exceptions import ( - AuthenticationFailure, - BadImage, - DateRangeError, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectHasNoAPIAccess, - ProjectInactive, - ProjectSuspended, - RequestQuotaReached, - RequestTimeTooSkewed, - TargetNameExist, - TargetQuotaReached, - TargetStatusNotSuccess, - TargetStatusProcessing, - UnknownTarget, +from vws._vws_request import target_api_request +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.custom_exceptions import ( + RecoCountsReportNotReadyError, + RecoCountsReportTimeoutError, + ServerError, + TargetProcessingTimeoutError, ) +from vws.exceptions.vws_exceptions import TooManyRequestsError from vws.reports import ( DatabaseSummaryReport, - TargetRecord, + RecoCountsReport, + RecoCountsReportRequest, TargetStatusAndRecord, TargetStatuses, TargetSummaryReport, ) +from vws.response import Response # noqa: TC001 +from vws.transports import RequestsTransport, Transport -def _target_api_request( - server_access_key: str, - server_secret_key: str, - method: str, - content: bytes, - request_path: str, - base_vws_url: str, -) -> Response: - """ - Make a request to the Vuforia Target API. - - This uses `requests` to make a request against https://vws.vuforia.com. - The content type of the request will be `application/json`. - - Args: - server_access_key: A VWS server access key. - server_secret_key: A VWS server secret key. - method: The HTTP method which will be used in the request. - content: The request body which will be used in the request. - request_path: The path to the endpoint which will be used in the - request. - base_vws_url: The base URL for the VWS API. - - Returns: - The response to the request made by `requests`. - """ - date_string = rfc_1123_date() - content_type = 'application/json' - - signature_string = authorization_header( - access_key=server_access_key, - secret_key=server_secret_key, - method=method, - content=content, - content_type=content_type, - date=date_string, - request_path=request_path, - ) - - headers = { - 'Authorization': signature_string, - 'Date': date_string, - 'Content-Type': content_type, - } - - url = urljoin(base=base_vws_url, url=request_path) - - response = requests.request( - method=method, - url=url, - headers=headers, - data=content, - ) - - return response - - +@beartype(conf=BeartypeConf(is_pep484_tower=True)) class VWS: - """ - An interface to Vuforia Web Services APIs. - """ + """An interface to Vuforia Web Services APIs.""" def __init__( self, + *, server_access_key: str, server_secret_key: str, - base_vws_url: str = 'https://vws.vuforia.com', + base_vws_url: str = "https://vws.vuforia.com", + database_id: str | None = None, + request_timeout_seconds: float | tuple[float, float] = 30.0, + transport: Transport | None = None, ) -> None: """ Args: server_access_key: A VWS server access key. server_secret_key: A VWS server secret key. base_vws_url: The base URL for the VWS API. + database_id: The ID of the database which the + given keys belong to. This is shown in the + target manager. It is needed only by + :meth:`request_database_reco_counts_report`. + request_timeout_seconds: The timeout for each + HTTP request. This can be a float to set both + the connect and read timeouts, or a + (connect, read) tuple. + transport: The HTTP transport to use for + requests. Defaults to + ``RequestsTransport()``. """ self._server_access_key = server_access_key self._server_secret_key = server_secret_key self._base_vws_url = base_vws_url + self._database_id = database_id + self._request_timeout_seconds = request_timeout_seconds + self._transport = ( + transport if transport is not None else RequestsTransport() + ) - def _make_request( + def make_request( self, + *, method: str, - content: bytes, + data: bytes, request_path: str, expected_result_code: str, + content_type: str, + extra_headers: dict[str, str] | None = None, ) -> Response: - """ - Make a request to the Vuforia Target API. - - This uses `requests` to make a request against https://vws.vuforia.com. - The content type of the request will be `application/json`. + """Make a request to the Vuforia Target API. Args: - method: The HTTP method which will be used in the request. - content: The request body which will be used in the request. - request_path: The path to the endpoint which will be used in the + method: The HTTP method which will be used in + the request. + data: The request body which will be used in the request. + request_path: The path to the endpoint which + will be used in the request. expected_result_code: See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Interperete-VWS-API-Result-Codes + "VWS API Result Codes" on + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. + content_type: The content type of the request. + extra_headers: Additional headers to include in + the request. Returns: - The response to the request made by `requests`. + The response to the request. Raises: - ~vws.exceptions.UnknownVWSErrorPossiblyBadName: Vuforia returns an - HTML page with the text "Oops, an error occurred". This has - been seen to happen when the given name includes a bad - character. + ~vws.exceptions.custom_exceptions.ServerError: + There is an error with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: + Vuforia is rate limiting access. + json.JSONDecodeError: The server did not respond + with valid JSON. This may happen if the + server address is not a valid Vuforia server. """ - response = _target_api_request( + response = target_api_request( + content_type=content_type, server_access_key=self._server_access_key, server_secret_key=self._server_secret_key, method=method, - content=content, + data=data, request_path=request_path, base_vws_url=self._base_vws_url, + request_timeout_seconds=self._request_timeout_seconds, + extra_headers=extra_headers or {}, + transport=self._transport, ) - try: - result_code = response.json()['result_code'] - except json.decoder.JSONDecodeError as exc: - assert 'Oops' in response.text, response.text - raise UnknownVWSErrorPossiblyBadName() from exc + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + raise TooManyRequestsError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + result_code = json.loads(s=response.text)["result_code"] if result_code == expected_result_code: return response - exception = { - 'AuthenticationFailure': AuthenticationFailure, - 'BadImage': BadImage, - 'DateRangeError': DateRangeError, - 'Fail': Fail, - 'ImageTooLarge': ImageTooLarge, - 'MetadataTooLarge': MetadataTooLarge, - 'ProjectHasNoAPIAccess': ProjectHasNoAPIAccess, - 'ProjectInactive': ProjectInactive, - 'ProjectSuspended': ProjectSuspended, - 'RequestQuotaReached': RequestQuotaReached, - 'RequestTimeTooSkewed': RequestTimeTooSkewed, - 'TargetNameExist': TargetNameExist, - 'TargetQuotaReached': TargetQuotaReached, - 'TargetStatusNotSuccess': TargetStatusNotSuccess, - 'TargetStatusProcessing': TargetStatusProcessing, - 'UnknownTarget': UnknownTarget, - }[result_code] - - raise exception(response=response) + raise VWSError.from_result_code( + result_code=result_code, + response=response, + ) def add_target( self, + *, name: str, - width: Union[int, float], - image: io.BytesIO, + width: float, + image: _ImageType, + application_metadata: str | None, active_flag: bool, - application_metadata: Optional[str], ) -> str: - """ - Add a target to a Vuforia Web Services database. + """Add a target to a Vuforia Web Services database. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Add-a-Target + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add for parameter details. Args: @@ -223,59 +177,62 @@ def add_target( The target ID of the new target. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.BadImage: There is a problem with - the given image. - For example, it must be a JPEG or PNG file in the grayscale or - RGB color space. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.BadImageError: There is a problem + with the given image. For example, it must be a JPEG or PNG + file in the grayscale or RGB color space. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.MetadataTooLarge: The given metadata - is too large. The maximum size is 1 MB of data when Base64 - encoded. - ~vws.exceptions.vws_exceptions.ImageTooLarge: The given image is - too large. - ~vws.exceptions.vws_exceptions.TargetNameExist: A target with the - given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactive: The project is + ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given + metadata is too large. The maximum size is 1 MB of data when + Base64 encoded. + ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image + is too large. + ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with + the given ``name`` already exists. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is inactive. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. - ~vws.exceptions.custom_exceptions.UnknownVWSErrorPossiblyBadName: - Vuforia returns an HTML page with the text "Oops, an error - occurred". This has been seen to happen when the given name - includes a bad character. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. This has been seen to happen when the + given name includes a bad character. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - image_data = image.getvalue() - image_data_encoded = base64.b64encode(image_data).decode('ascii') + image_data = _get_image_data(image=image) + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) data = { - 'name': name, - 'width': width, - 'image': image_data_encoded, - 'active_flag': active_flag, - 'application_metadata': application_metadata, + "name": name, + "width": width, + "image": image_data_encoded, + "active_flag": active_flag, + "application_metadata": application_metadata, } - content = bytes(json.dumps(data), encoding='utf-8') + content = json.dumps(obj=data).encode(encoding="utf-8") - response = self._make_request( - method='POST', - content=content, - request_path='/targets', - expected_result_code='TargetCreated', + response = self.make_request( + method=HTTPMethod.POST, + data=content, + request_path="/targets", + expected_result_code="TargetCreated", + content_type="application/json", ) - return str(response.json()['target_id']) + return str(object=json.loads(s=response.text)["target_id"]) def get_target_record(self, target_id: str) -> TargetStatusAndRecord: - """ - Get a given target's target record from the Target Management System. + """Get a given target's target record from the Target Management + System. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Retrieve-a-Target-Record. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. Args: target_id: The ID of the target to get details of. @@ -284,81 +241,41 @@ def get_target_record(self, target_id: str) -> TargetStatusAndRecord: Response details of a target from Vuforia. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method='GET', - content=b'', - request_path=f'/targets/{target_id}', - expected_result_code='Success', + response = self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=f"/targets/{target_id}", + expected_result_code="Success", + content_type="application/json", ) - result_data = response.json() - status = TargetStatuses(result_data['status']) - target_record_dict = dict(result_data['target_record']) - target_record = TargetRecord( - target_id=target_record_dict['target_id'], - active_flag=target_record_dict['active_flag'], - name=target_record_dict['name'], - width=target_record_dict['width'], - tracking_rating=target_record_dict['tracking_rating'], - reco_rating=target_record_dict['reco_rating'], - ) - target_status_and_record = TargetStatusAndRecord( - status=status, - target_record=target_record, + result_data = json.loads(s=response.text) + return TargetStatusAndRecord.from_response_dict( + response_dict=result_data, ) - return target_status_and_record - - def _wait_for_target_processed( - self, - target_id: str, - seconds_between_requests: float, - ) -> None: - """ - Wait indefinitely for a target to get past the processing stage. - - Args: - target_id: The ID of the target to wait for. - seconds_between_requests: The number of seconds to wait between - requests made while polling the target status. - - Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a - known database. - TimeoutError: The target remained in the processing stage for more - than five minutes. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. - """ - while True: - report = self.get_target_summary_report(target_id=target_id) - if report.status != TargetStatuses.PROCESSING: - return - - sleep(seconds_between_requests) def wait_for_target_processed( self, + *, target_id: str, seconds_between_requests: float = 0.2, - timeout_seconds: Optional[float] = 60 * 5, + timeout_seconds: float = 60 * 5, ) -> None: - """ - Wait up to five minutes (arbitrary) for a target to get past the + """Wait up to five minutes (arbitrary) for a target to get past the processing stage. Args: @@ -369,70 +286,79 @@ def wait_for_target_processed( decrease the number of calls made to the API, to decrease the likelihood of hitting the request quota. timeout_seconds: The maximum number of seconds to wait for the - target to be processed. If ``None`` is given, no maximum is - applied. + target to be processed. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.custom_exceptions.TargetProcessingTimeout: The + ~vws.exceptions.custom_exceptions.TargetProcessingTimeoutError: The target remained in the processing stage for more than ``timeout_seconds`` seconds. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ + start_time = time.monotonic() + while True: + report = self.get_target_summary_report(target_id=target_id) + if report.status != TargetStatuses.PROCESSING: + # Guard against the target still being seen as + # processing by other endpoints due to eventual + # consistency. + time.sleep(seconds_between_requests) + return - @func_set_timeout(timeout=timeout_seconds) - def decorated() -> None: - self._wait_for_target_processed( - target_id=target_id, - seconds_between_requests=seconds_between_requests, - ) + elapsed_time = time.monotonic() - start_time + if elapsed_time > timeout_seconds: # pragma: no cover + raise TargetProcessingTimeoutError - try: - decorated() - except FunctionTimedOut as exc: - raise TargetProcessingTimeout from exc + time.sleep(seconds_between_requests) - def list_targets(self) -> List[str]: - """ - List target IDs. + def list_targets(self) -> list[str]: + """List target IDs. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Get-a-Target-List-for-a-Cloud-Database. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list. Returns: The IDs of all targets in the database. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method='GET', - content=b'', - request_path='/targets', - expected_result_code='Success', + response = self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path="/targets", + expected_result_code="Success", + content_type="application/json", ) - return list(response.json()['results']) + return list(json.loads(s=response.text)["results"]) def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: - """ - Get a summary report for a target. + """Get a summary report for a target. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Retrieve-a-Target-Summary-Report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. Args: target_id: The ID of the target to get a summary report for. @@ -441,115 +367,238 @@ def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: Details of the target. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method='GET', - content=b'', - request_path=f'/summary/{target_id}', - expected_result_code='Success', + response = self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=f"/summary/{target_id}", + expected_result_code="Success", + content_type="application/json", ) - result_data = dict(response.json()) - return TargetSummaryReport( - status=TargetStatuses(result_data['status']), - database_name=result_data['database_name'], - target_name=result_data['target_name'], - upload_date=date.fromisoformat(result_data['upload_date']), - active_flag=result_data['active_flag'], - tracking_rating=result_data['tracking_rating'], - total_recos=result_data['total_recos'], - current_month_recos=result_data['current_month_recos'], - previous_month_recos=result_data['previous_month_recos'], + result_data = dict(json.loads(s=response.text)) + return TargetSummaryReport.from_response_dict( + response_dict=result_data, ) def get_database_summary_report(self) -> DatabaseSummaryReport: - """ - Get a summary report for the database. + """Get a summary report for the database. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Get-a-Database-Summary-Report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. Returns: Details of the database. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method='GET', - content=b'', - request_path='/summary', - expected_result_code='Success', + response = self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path="/summary", + expected_result_code="Success", + content_type="application/json", ) - response_data = dict(response.json()) - database_summary_report = DatabaseSummaryReport( - active_images=response_data['active_images'], - current_month_recos=response_data['current_month_recos'], - failed_images=response_data['failed_images'], - inactive_images=response_data['inactive_images'], - name=response_data['name'], - previous_month_recos=response_data['previous_month_recos'], - processing_images=response_data['processing_images'], - reco_threshold=response_data['reco_threshold'], - request_quota=response_data['request_quota'], - request_usage=response_data['request_usage'], - target_quota=response_data['target_quota'], - total_recos=response_data['total_recos'], + response_data = dict(json.loads(s=response.text)) + return DatabaseSummaryReport.from_response_dict( + response_dict=response_data, ) - return database_summary_report - def delete_target(self, target_id: str) -> None: + def request_database_reco_counts_report( + self, + *, + year: int, + month: calendar.Month, + ) -> RecoCountsReportRequest: + """Request a per-target recognition count report for the database. + + Vuforia generates the report in the background, so the report is not + available to download immediately. Use + :meth:`wait_for_reco_counts_report` to wait for it. + + Args: + year: The year to get recognition counts for. + month: The month of the year to get recognition counts for. + Vuforia accepts only the current month and the previous + month. A month taken from a :class:`datetime.datetime` needs + wrapping, as in ``calendar.Month(value=now.month)``. + + Returns: + The URL to download the report from, and the transaction ID of + the request. + + Raises: + ~vws.exceptions.custom_exceptions.DatabaseIdNotSetError: No + ``database_id`` was given to the client. + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct, or the client's ``database_id`` is + not the ID of the database which the client's keys belong to. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given year and month are not + the current month or the previous month. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + """ + response = self.make_request( + method=HTTPMethod.POST, + data=reco_counts_report_body(year=year, month=month), + request_path=reco_counts_report_path( + database_id=self._database_id, + ), + expected_result_code="Success", + content_type="application/json", + ) + + response_data = dict(json.loads(s=response.text)) + return RecoCountsReportRequest.from_response_dict( + response_dict=response_data, + ) + + def download_reco_counts_report( + self, + *, + presigned_url: str, + ) -> RecoCountsReport: + """Download a requested reco counts report. + + The report's URL is not part of the VWS API, so this request is not + authorized with the client's keys. + + Args: + presigned_url: The URL of the report, as given by + :meth:`request_database_reco_counts_report`. + + Returns: + The downloaded report. + + Raises: + ~vws.exceptions.custom_exceptions.RecoCountsReportNotReadyError: + Vuforia has not finished generating the report. + ~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError: + The report could not be downloaded. For example, the report's + URL may have expired. """ - Delete a given target. + response = self._transport( + method=HTTPMethod.GET, + url=presigned_url, + headers={}, + data=b"", + request_timeout=self._request_timeout_seconds, + ) + + return report_from_download_response(response=response) + + def wait_for_reco_counts_report( + self, + *, + presigned_url: str, + seconds_between_requests: float = 0.2, + timeout_seconds: float = 60 * 5, + ) -> RecoCountsReport: + """Wait for a requested reco counts report to be generated, then + download it. + + Args: + presigned_url: The URL of the report, as given by + :meth:`request_database_reco_counts_report`. + seconds_between_requests: The number of seconds to wait between + requests made while polling the report's URL. + timeout_seconds: The maximum number of seconds to wait for the + report to be generated. + + Returns: + The downloaded report. + + Raises: + ~vws.exceptions.custom_exceptions.RecoCountsReportTimeoutError: + The report was not generated within ``timeout_seconds`` + seconds. + ~vws.exceptions.custom_exceptions.RecoCountsReportDownloadError: + The report could not be downloaded. For example, the report's + URL may have expired. + """ + start_time = time.monotonic() + while True: + try: + return self.download_reco_counts_report( + presigned_url=presigned_url, + ) + except RecoCountsReportNotReadyError: + elapsed_time = time.monotonic() - start_time + if elapsed_time > timeout_seconds: + raise RecoCountsReportTimeoutError from None + + time.sleep(seconds_between_requests) + + def delete_target(self, target_id: str) -> None: + """Delete a given target. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Delete-a-Target. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete. Args: target_id: The ID of the target to delete. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.TargetStatusProcessing: The given - target is in the processing state. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.TargetStatusProcessingError: The + given target is in the processing state. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - self._make_request( - method='DELETE', - content=b'', - request_path=f'/targets/{target_id}', - expected_result_code='Success', + self.make_request( + method=HTTPMethod.DELETE, + data=b"", + request_path=f"/targets/{target_id}", + expected_result_code="Success", + content_type="application/json", ) - def get_duplicate_targets(self, target_id: str) -> List[str]: - """ - Get targets which may be considered duplicates of a given target. + def get_duplicate_targets(self, target_id: str) -> list[str]: + """Get targets which may be considered duplicates of a given + target. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API.html#How-To-Check-for-Duplicate-Targets. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check. Args: target_id: The ID of the target to delete. @@ -558,45 +607,50 @@ def get_duplicate_targets(self, target_id: str) -> List[str]: The target IDs of duplicate targets. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.ProjectInactive: The project is + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is inactive. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method='GET', - content=b'', - request_path=f'/duplicates/{target_id}', - expected_result_code='Success', + response = self.make_request( + method=HTTPMethod.GET, + data=b"", + request_path=f"/duplicates/{target_id}", + expected_result_code="Success", + content_type="application/json", ) - return list(response.json()['similar_targets']) + return list(json.loads(s=response.text)["similar_targets"]) def update_target( self, + *, target_id: str, - name: Optional[str] = None, - width: Optional[Union[int, float]] = None, - image: Optional[io.BytesIO] = None, - active_flag: Optional[bool] = None, - application_metadata: Optional[str] = None, + name: str | None = None, + width: float | None = None, + image: _ImageType | None = None, + active_flag: bool | None = None, + application_metadata: str | None = None, ) -> None: - """ - Add a target to a Vuforia Web Services database. + """Update a target in a Vuforia Web Services database. See - https://library.vuforia.com/articles/Solution/How-To-Use-the-Vuforia-Web-Services-API#How-To-Add-a-Target + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update for parameter details. Args: - target_id: The ID of the target to get details of. + target_id: The ID of the target to update. name: The name of the target. width: The width of the target. image: The image of the target. @@ -609,50 +663,57 @@ def update_target( Giving ``None`` will not change the application metadata. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.BadImage: There is a problem with - the given image. For example, it must be a JPEG or PNG file in - the grayscale or RGB color space. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.BadImageError: There is a problem + with the given image. For example, it must be a JPEG or PNG + file in the grayscale or RGB color space. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.MetadataTooLarge: The given metadata - is too large. The maximum size is 1 MB of data when Base64 - encoded. - ~vws.exceptions.vws_exceptions.ImageTooLarge: The given image is - too large. - ~vws.exceptions.vws_exceptions.TargetNameExist: A target with the - given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactive: The project is + ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given + metadata is too large. The maximum size is 1 MB of data when + Base64 encoded. + ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image + is too large. + ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with + the given ``name`` already exists. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is inactive. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - data: Dict[str, Union[str, bool, float, int]] = {} + data: dict[str, str | bool | float | int] = {} if name is not None: - data['name'] = name + data["name"] = name if width is not None: - data['width'] = width + data["width"] = width if image is not None: - image_data = image.getvalue() - image_data_encoded = base64.b64encode(image_data).decode('ascii') - data['image'] = image_data_encoded + image_data = _get_image_data(image=image) + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + data["image"] = image_data_encoded if active_flag is not None: - data['active_flag'] = active_flag + data["active_flag"] = active_flag if application_metadata is not None: - data['application_metadata'] = application_metadata + data["application_metadata"] = application_metadata - content = bytes(json.dumps(data), encoding='utf-8') + content = json.dumps(obj=data).encode(encoding="utf-8") - self._make_request( - method='PUT', - content=content, - request_path=f'/targets/{target_id}', - expected_result_code='Success', + self.make_request( + method=HTTPMethod.PUT, + data=content, + request_path=f"/targets/{target_id}", + expected_result_code="Success", + content_type="application/json", ) diff --git a/tests/conftest.py b/tests/conftest.py index a3b1d7099..74d4137be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,46 +1,243 @@ -""" -Configuration, plugins and fixtures for `pytest`. -""" +"""Configuration, plugins and fixtures for `pytest`.""" -from typing import Iterator +import datetime +import io # noqa: TC003 +from collections.abc import AsyncGenerator, Generator # noqa: TC003 +from pathlib import Path # noqa: TC003 +from typing import BinaryIO, Literal import pytest +import pytest_asyncio from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.target import VuMarkTarget -from vws import VWS, CloudRecoService +from vws import ( + VWS, + AsyncCloudRecoService, + AsyncModelTargetService, + AsyncVuMarkService, + AsyncVWS, + CloudRecoService, + ModelTargetService, + VuMarkService, +) +from vws.model_target_datasets import ( + CadDataFormat, + GuideViewPosition, + ModelTargetModel, + ModelTargetView, +) +# The mock accepts one hard-coded pair of Model Target Web API OAuth2 +# credentials, which it does not expose. +_MODEL_TARGET_CLIENT_ID = "client-id" +_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105 -@pytest.fixture() -def _mock_database() -> Iterator[VuforiaDatabase]: - """ - Yield a mock ``VuforiaDatabase``. - """ + +@pytest.fixture(name="_mock_database") +def fixture_mock_database() -> Generator[CloudDatabase]: + """Yield a mock ``CloudDatabase``.""" + # We use a low processing time so that tests run quickly. + with MockVWS(processing_time_seconds=0.2) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + yield database + + +@pytest.fixture(name="_mock_vumark_database") +def fixture_mock_vumark_database() -> Generator[VuMarkDatabase]: + """Yield a mock ``VuMarkDatabase`` with a template target.""" + vumark_target = VuMarkTarget(name="vumark-template") with MockVWS() as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = VuMarkDatabase(vumark_targets={vumark_target}) + mock.add_vumark_database(vumark_database=database) yield database -@pytest.fixture() -def vws_client(_mock_database: VuforiaDatabase) -> Iterator[VWS]: - """ - Yield a VWS client which connects to a mock database. - """ - yield VWS( +@pytest.fixture +def vumark_service_client( + *, + _mock_vumark_database: VuMarkDatabase, +) -> VuMarkService: + """A ``VuMarkService`` client which connects to a mock VuMark database.""" + return VuMarkService( + server_access_key=_mock_vumark_database.server_access_key, + server_secret_key=_mock_vumark_database.server_secret_key, + ) + + +@pytest.fixture +def vumark_target_id(*, _mock_vumark_database: VuMarkDatabase) -> str: + """The ID of the VuMark template target.""" + (target,) = _mock_vumark_database.vumark_targets + return target.target_id + + +@pytest.fixture +def vws_client(*, _mock_database: CloudDatabase) -> VWS: + """A VWS client which connects to a mock database.""" + return VWS( server_access_key=_mock_database.server_access_key, server_secret_key=_mock_database.server_secret_key, + database_id=_mock_database.database_id, ) -@pytest.fixture() -def cloud_reco_client( - _mock_database: VuforiaDatabase, -) -> Iterator[CloudRecoService]: - """ - Yield a ``CloudRecoService`` client which connects to a mock database. +@pytest.fixture +def cloud_reco_client(*, _mock_database: CloudDatabase) -> CloudRecoService: + """A ``CloudRecoService`` client which connects to a mock database.""" + return CloudRecoService( + client_access_key=_mock_database.client_access_key, + client_secret_key=_mock_database.client_secret_key, + ) + + +@pytest_asyncio.fixture +async def async_vws_client( + *, + _mock_database: CloudDatabase, +) -> AsyncGenerator[AsyncVWS]: + """An async VWS client which connects to a mock database.""" + async with AsyncVWS( + server_access_key=_mock_database.server_access_key, + server_secret_key=_mock_database.server_secret_key, + database_id=_mock_database.database_id, + ) as client: + yield client + + +@pytest_asyncio.fixture +async def async_cloud_reco_client( + *, + _mock_database: CloudDatabase, +) -> AsyncGenerator[AsyncCloudRecoService]: + """An async ``CloudRecoService`` client which connects to a mock + database. """ - yield CloudRecoService( + async with AsyncCloudRecoService( client_access_key=_mock_database.client_access_key, client_secret_key=_mock_database.client_secret_key, + ) as client: + yield client + + +@pytest_asyncio.fixture +async def async_vumark_service_client( + *, + _mock_vumark_database: VuMarkDatabase, +) -> AsyncGenerator[AsyncVuMarkService]: + """An async ``VuMarkService`` client which connects to a mock VuMark + database. + """ + async with AsyncVuMarkService( + server_access_key=_mock_vumark_database.server_access_key, + server_secret_key=_mock_vumark_database.server_secret_key, + ) as client: + yield client + + +@pytest.fixture(name="_mock_model_targets") +def fixture_mock_model_targets() -> Generator[None]: + """Yield a mock which serves the Model Target Web API. + + The Model Target Web API is not tied to a VWS database, so no + database is added. + """ + # We use a low processing time so that tests run quickly. + with MockVWS(processing_time_seconds=0.2): + yield + + +@pytest.fixture +def model_target_client( + *, + _mock_model_targets: None, +) -> ModelTargetService: + """A ``ModelTargetService`` client which connects to a mock.""" + return ModelTargetService( + client_id=_MODEL_TARGET_CLIENT_ID, + client_secret=_MODEL_TARGET_CLIENT_SECRET, + ) + + +@pytest_asyncio.fixture +async def async_model_target_client( + *, + _mock_model_targets: None, +) -> AsyncGenerator[AsyncModelTargetService]: + """An async ``ModelTargetService`` client which connects to a mock.""" + async with AsyncModelTargetService( + client_id=_MODEL_TARGET_CLIENT_ID, + client_secret=_MODEL_TARGET_CLIENT_SECRET, + ) as client: + yield client + + +@pytest.fixture(name="model_target_model") +def fixture_model_target_model() -> ModelTargetModel: + """A model which Vuforia accepts for dataset creation.""" + return ModelTargetModel( + name="model", + cad_data_url="https://example.com/model.zip", + cad_data_format=CadDataFormat.ZIP, + views=[ + ModelTargetView( + name="front", + guide_view_position=GuideViewPosition( + rotation=[0.0, 0.0, 0.0, 1.0], + translation=[0.0, 0.0, 1.0], + ), + ), + ], ) + + +@pytest.fixture(name="current_month") +def fixture_current_month() -> datetime.date: + """The current month, as the first day of that month.""" + now = datetime.datetime.now(tz=datetime.UTC) + return now.date().replace(day=1) + + +@pytest.fixture(name="report_month", params=["current", "previous"]) +def fixture_report_month(*, request: pytest.FixtureRequest) -> datetime.date: + """A month which a reco counts report can be requested for. + + Vuforia accepts only the current month and the previous month. + """ + now = datetime.datetime.now(tz=datetime.UTC) + first_of_month = now.date().replace(day=1) + if request.param == "current": + return first_of_month + + return first_of_month - datetime.timedelta(days=1) + + +@pytest.fixture(name="image_file", params=["r+b", "rb"]) +def fixture_image_file( + *, + high_quality_image: io.BytesIO, + tmp_path: Path, + request: pytest.FixtureRequest, +) -> Generator[BinaryIO]: + """An image file object.""" + file = tmp_path / "image.jpg" + buffer = high_quality_image.getvalue() + file.write_bytes(data=buffer) + mode: Literal["r+b", "rb"] = request.param + with file.open(mode=mode) as file_obj: + yield file_obj + + +@pytest.fixture(params=["high_quality_image", "image_file"]) +def image( + *, + request: pytest.FixtureRequest, + high_quality_image: io.BytesIO, + image_file: BinaryIO, +) -> io.BytesIO | BinaryIO: + """An image in any of the types that the API accepts.""" + if request.param == "high_quality_image": + return high_quality_image + return image_file diff --git a/tests/test_async_cloud_reco_exceptions.py b/tests/test_async_cloud_reco_exceptions.py new file mode 100644 index 000000000..a9c1395a5 --- /dev/null +++ b/tests/test_async_cloud_reco_exceptions.py @@ -0,0 +1,194 @@ +"""Tests for exceptions raised when using the +AsyncCloudRecoService. +""" + +import io # noqa: TC003 +import json +import uuid +from http import HTTPStatus + +import pytest +from mock_vws import CloudQueryFailureResponse, MockVWS +from mock_vws.database import CloudDatabase +from mock_vws.states import States + +from vws import AsyncCloudRecoService +from vws.exceptions.base_exceptions import CloudRecoError +from vws.exceptions.cloud_reco_exceptions import ( + AuthenticationFailureError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, +) +from vws.exceptions.custom_exceptions import ( + RequestEntityTooLargeError, +) + + +@pytest.mark.asyncio +async def test_too_many_max_results( + *, + async_cloud_reco_client: AsyncCloudRecoService, + high_quality_image: io.BytesIO, +) -> None: + """A ``MaxNumResultsOutOfRange`` error is raised if the given + ``max_num_results`` is out of range. + """ + with pytest.raises( + expected_exception=MaxNumResultsOutOfRangeError, + ) as exc: + await async_cloud_reco_client.query( + image=high_quality_image, + max_num_results=51, + ) + + expected_value = ( + "Integer out of range (51) in form data part " + "'max_result'. " + "Accepted range is from 1 to 50 (inclusive)." + ) + assert str(object=exc.value) == exc.value.response.text == expected_value + + +@pytest.mark.asyncio +async def test_image_too_large( + *, + async_cloud_reco_client: AsyncCloudRecoService, + png_too_large: io.BytesIO | io.BufferedRandom, +) -> None: + """A ``RequestEntityTooLarge`` exception is raised if an + image which is too large is given. + """ + with pytest.raises( + expected_exception=RequestEntityTooLargeError, + ) as exc: + await async_cloud_reco_client.query( + image=png_too_large, + ) + + assert ( + exc.value.response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + ) + + +@pytest.mark.asyncio +async def test_authentication_failure( + high_quality_image: io.BytesIO, +) -> None: + """An ``AuthenticationFailure`` exception is raised when the + client secret key is incorrect. + """ + database = CloudDatabase() + async_cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=uuid.uuid4().hex, + ) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + await async_cloud_reco_client.query( + image=high_quality_image, + ) + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_inactive_project( + high_quality_image: io.BytesIO, +) -> None: + """An ``InactiveProject`` exception is raised when querying + an inactive database. + """ + database = CloudDatabase(state=States.PROJECT_INACTIVE) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with pytest.raises( + expected_exception=InactiveProjectError, + ) as exc: + await async_cloud_reco_client.query( + image=high_quality_image, + ) + + response = exc.value.response + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.tell_position != 0 + + +@pytest.mark.parametrize( + argnames=("body", "headers"), + argvalues=[ + ("", {"X-Query-Failure": "empty"}), + ( + "Arbitrary upstream failure", + { + "Content-Type": "application/json", + "X-Query-Failure": "text", + }, + ), + ], + ids=["empty", "arbitrary-text"], +) +@pytest.mark.asyncio +async def test_non_json_client_error( + *, + high_quality_image: io.BytesIO, + body: str, + headers: dict[str, str], +) -> None: + """Non-JSON 4xx responses raise a response-carrying error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.BAD_REQUEST, + headers=headers, + body=body, + ) + cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=CloudRecoError) as exc: + await cloud_reco_client.query(image=high_quality_image) + + response = exc.value.response + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.text == body + assert response.content == body.encode() + response_headers = { + key.lower(): value for key, value in response.headers.items() + } + assert response_headers["x-query-failure"] == headers["X-Query-Failure"] + assert response.request_body + + +@pytest.mark.asyncio +async def test_non_json_success_response( + *, + high_quality_image: io.BytesIO, +) -> None: + """Malformed successful responses retain the JSON parsing error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.OK, + headers={"Content-Type": "application/json"}, + body="Not JSON", + ) + cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=json.JSONDecodeError): + await cloud_reco_client.query(image=high_quality_image) diff --git a/tests/test_async_model_targets.py b/tests/test_async_model_targets.py new file mode 100644 index 000000000..4bcd6a2f3 --- /dev/null +++ b/tests/test_async_model_targets.py @@ -0,0 +1,409 @@ +"""Tests for the async Model Target Web API client.""" + +import io +import json +import uuid +import zipfile +from http import HTTPStatus + +import pytest +from mock_vws import ( + MockVWS, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) + +from vws import AsyncModelTargetService +from vws.exceptions.model_target_exceptions import ( + ModelTargetDatasetNotDoneError, + ModelTargetDatasetTimeoutError, + ModelTargetOAuth2Error, + ModelTargetValidationError, + UnknownModelTargetDatasetError, +) +from vws.model_target_datasets import ( + CadDataFormat, + ModelTargetDatasetType, + ModelTargetModel, + RealisticAppearance, +) +from vws.reports import ModelTargetDatasetStatuses + +# The mock accepts one hard-coded pair of Model Target Web API OAuth2 +# credentials, which it does not expose. +_CLIENT_ID = "client-id" +_CLIENT_SECRET = "client-secret" # noqa: S105 + +_DATASET_TYPES = [ + ModelTargetDatasetType.STANDARD, + ModelTargetDatasetType.ADVANCED, +] + + +class TestAccessToken: + """Tests for getting an access token.""" + + @staticmethod + @pytest.mark.asyncio + async def test_token_is_a_bearer_token( + *, + async_model_target_client: AsyncModelTargetService, + ) -> None: + """An access token is given for valid credentials.""" + assert await async_model_target_client.get_access_token() + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.usefixtures("_mock_model_targets") + async def test_invalid_credentials() -> None: + """An exception is raised when the credentials are not known.""" + async with AsyncModelTargetService( + client_id="not-a-client-id", + client_secret="not-a-client-secret", # noqa: S106 + ) as client: + with pytest.raises( + expected_exception=ModelTargetOAuth2Error, + ) as exc: + await client.get_access_token() + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + assert exc.value.error == "invalid_client" + + +class TestDatasetLifecycle: + """Tests for the dataset lifecycle.""" + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize( + argnames="dataset_type", + argvalues=_DATASET_TYPES, + ) + async def test_create_wait_download_delete( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, + dataset_type: ModelTargetDatasetType, + ) -> None: + """A dataset can be created, downloaded and then deleted.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=dataset_type, + ) + + report = await async_model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.dataset_uuid == dataset_uuid + assert report.completed_at is not None + assert report.eta is None + assert report.error is None + assert report.warning is None + + dataset = await async_model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=dataset) + ) as archive: + dataset_json = json.loads(s=archive.read(name="dataset.json")) + + assert dataset_json["uuid"] == dataset_uuid + assert dataset_json["type"] == dataset_type.value + + await async_model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + @staticmethod + @pytest.mark.asyncio + async def test_status_while_processing( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """A processing dataset has an estimated completion time.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.PROCESSING + assert report.eta is not None + assert report.completed_at is None + + @staticmethod + @pytest.mark.asyncio + async def test_download_while_processing( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset cannot be downloaded before it is generated.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetNotDoneError, + ) as exc: + await async_model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert ( + exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + ) + assert exc.value.code == "UNSUPPORTED_STATE" + + @staticmethod + @pytest.mark.asyncio + async def test_dataset_types_are_separate( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset is not visible to requests for the other type.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @staticmethod + @pytest.mark.asyncio + async def test_advanced_dataset_takes_multiple_models( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """An advanced dataset can be generated from multiple models.""" + other_model = ModelTargetModel( + name="other-model", + cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", + cad_data_format=CadDataFormat.GLB, + realistic_appearance=RealisticAppearance.TRUE, + ) + + assert await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model, other_model], + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + +class TestUnknownDataset: + """Tests for requests for datasets which do not exist.""" + + @staticmethod + @pytest.mark.asyncio + async def test_get_status( + *, + async_model_target_client: AsyncModelTargetService, + ) -> None: + """An exception is raised for an unknown dataset.""" + dataset_uuid = uuid.uuid4().hex + with pytest.raises( + expected_exception=UnknownModelTargetDatasetError, + ) as exc: + await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + assert dataset_uuid in exc.value.message + + @staticmethod + @pytest.mark.asyncio + async def test_download( + *, + async_model_target_client: AsyncModelTargetService, + ) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + await async_model_target_client.download_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @staticmethod + @pytest.mark.asyncio + async def test_delete( + *, + async_model_target_client: AsyncModelTargetService, + ) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + await async_model_target_client.delete_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +class TestValidation: + """Tests for requests which Vuforia rejects.""" + + @staticmethod + @pytest.mark.asyncio + async def test_no_cad_data( + *, + async_model_target_client: AsyncModelTargetService, + ) -> None: + """A model needs exactly one CAD data source.""" + with pytest.raises( + expected_exception=ModelTargetValidationError, + ) as exc: + await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[ModelTargetModel(name="model")], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + (detail,) = exc.value.details + assert detail.code == "VALIDATION_ERROR" + + +class TestGenerationResult: + """Tests for datasets which Vuforia does not generate cleanly.""" + + @staticmethod + @pytest.mark.asyncio + async def test_generation_failure( + *, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset which fails to generate reports the failure.""" + message = "Model Target dataset generation failed" + failure = ModelTargetGenerationFailure(message=message) + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_failure=failure, + ): + async with AsyncModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) as client: + dataset_uuid = await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = await client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.FAILED + assert report.error is not None + assert report.error.message == message + + @staticmethod + @pytest.mark.asyncio + async def test_generation_warning( + *, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset which generates with a warning reports the + warning. + """ + warning = ModelTargetGenerationWarning() + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_warning=warning, + ): + async with AsyncModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) as client: + dataset_uuid = await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = await client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.warning is not None + assert report.warning.target == dataset_uuid + (detail,) = report.warning.details + assert detail.code == "LOW_RECOGNITION_QUALITY" + + +class TestWaitForDatasetGenerated: + """Tests for waiting for a dataset to be generated.""" + + @staticmethod + @pytest.mark.asyncio + async def test_timeout(*, model_target_model: ModelTargetModel) -> None: + """An exception is raised when the wait times out.""" + with MockVWS(processing_time_seconds=60): + async with AsyncModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) as client: + dataset_uuid = await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetTimeoutError, + ): + await client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + report = await client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.PROCESSING diff --git a/tests/test_async_query.py b/tests/test_async_query.py new file mode 100644 index 000000000..2026d491a --- /dev/null +++ b/tests/test_async_query.py @@ -0,0 +1,225 @@ +"""Tests for the ``AsyncCloudRecoService`` querying functionality.""" + +import io # noqa: TC003 +import uuid +from typing import BinaryIO + +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import AsyncCloudRecoService, AsyncVWS +from vws.include_target_data import CloudRecoIncludeTargetData + + +class TestQuery: + """Tests for making async image queries.""" + + @staticmethod + @pytest.mark.asyncio + async def test_no_matches( + *, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, + ) -> None: + """An empty list is returned if there are no matches.""" + result = await async_cloud_reco_client.query( + image=image, + ) + assert result == [] + + @staticmethod + @pytest.mark.asyncio + async def test_match( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, + ) -> None: + """Details of matching targets are returned.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + [matching_target] = await async_cloud_reco_client.query( + image=image, + ) + assert matching_target.target_id == target_id + + +class TestCustomBaseVWQURL: + """Tests for using a custom base VWQ URL.""" + + @staticmethod + @pytest.mark.asyncio + async def test_custom_base_url( + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to query a target in a database under + a custom VWQ URL. + """ + base_vwq_url = "http://example.com" + with MockVWS(base_vwq_url=base_vwq_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + + async_cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + base_vwq_url=base_vwq_url, + ) + + matches = await async_cloud_reco_client.query( + image=image, + ) + assert len(matches) == 1 + match = matches[0] + assert match.target_id == target_id + + +class TestMaxNumResults: + """Tests for the ``max_num_results`` parameter of + ``query``. + """ + + @staticmethod + @pytest.mark.asyncio + async def test_custom( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to set a custom + ``max_num_results``. + """ + target_id = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_3 = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id_2, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id_3, + ) + max_num_results = 2 + matches = await async_cloud_reco_client.query( + image=image, + max_num_results=max_num_results, + ) + assert len(matches) == max_num_results + + +class TestIncludeTargetData: + """Tests for the ``include_target_data`` parameter of + ``query``. + """ + + @staticmethod + @pytest.mark.asyncio + async def test_none( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, + ) -> None: + """When ``CloudRecoIncludeTargetData.NONE`` is given, + target data is not returned in any match. + """ + target_id = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + [match] = await async_cloud_reco_client.query( + image=image, + include_target_data=(CloudRecoIncludeTargetData.NONE), + ) + assert match.target_data is None + + @staticmethod + @pytest.mark.asyncio + async def test_all( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, + ) -> None: + """When ``CloudRecoIncludeTargetData.ALL`` is given, + target data is returned in all matches. + """ + target_id = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id_2, + ) + top_match, second_match = await async_cloud_reco_client.query( + image=image, + max_num_results=2, + include_target_data=(CloudRecoIncludeTargetData.ALL), + ) + assert top_match.target_data is not None + assert second_match.target_data is not None diff --git a/tests/test_async_vws.py b/tests/test_async_vws.py new file mode 100644 index 000000000..c99644bec --- /dev/null +++ b/tests/test_async_vws.py @@ -0,0 +1,731 @@ +"""Tests for async helper functions for managing a Vuforia database.""" + +import base64 +import calendar +import datetime # noqa: TC003 +import io # noqa: TC003 +import time +import uuid +from http import HTTPStatus +from typing import BinaryIO + +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import AsyncCloudRecoService, AsyncVuMarkService, AsyncVWS +from vws.exceptions.custom_exceptions import ( + DatabaseIdNotSetError, + RecoCountsReportDownloadError, + RecoCountsReportNotReadyError, + RecoCountsReportTimeoutError, + TargetProcessingTimeoutError, +) +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + FailError, +) +from vws.reports import ( + DatabaseSummaryReport, + TargetRecord, + TargetStatuses, +) +from vws.response import Response +from vws.vumark_accept import VuMarkAccept + + +class TestAddTarget: + """Tests for adding a target.""" + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize( + argnames="application_metadata", + argvalues=[None, b"a"], + ) + @pytest.mark.parametrize( + argnames="active_flag", + argvalues=[True, False], + ) + async def test_add_target( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + application_metadata: bytes | None, + async_cloud_reco_client: AsyncCloudRecoService, + active_flag: bool, + ) -> None: + """No exception is raised when adding one target.""" + name = "x" + width = 1 + if application_metadata is None: + encoded_metadata = None + else: + encoded_metadata_bytes = base64.b64encode( + s=application_metadata, + ) + encoded_metadata = encoded_metadata_bytes.decode( + encoding="utf-8", + ) + + target_id = await async_vws_client.add_target( + name=name, + width=width, + image=image, + application_metadata=encoded_metadata, + active_flag=active_flag, + ) + target_record = ( + await async_vws_client.get_target_record( + target_id=target_id, + ) + ).target_record + assert target_record.name == name + assert target_record.width == width + assert target_record.active_flag is active_flag + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + matching_targets = await async_cloud_reco_client.query( + image=image, + ) + if active_flag: + [matching_target] = matching_targets + assert matching_target.target_id == target_id + assert matching_target.target_data is not None + query_metadata = matching_target.target_data.application_metadata + assert query_metadata == encoded_metadata + else: + assert matching_targets == [] + + @staticmethod + @pytest.mark.asyncio + async def test_add_two_targets( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """No exception is raised when adding two targets with + different names. + + This demonstrates that the image seek position is not + changed. + """ + for name in ("a", "b"): + await async_vws_client.add_target( + name=name, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +class TestCustomBaseVWSURL: + """Tests for using a custom base VWS URL.""" + + @staticmethod + @pytest.mark.asyncio + async def test_custom_base_url( + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to add a target to a database under a + custom VWS URL. + """ + base_vws_url = "http://example.com" + with MockVWS(base_vws_url=base_vws_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + ) + + await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +class TestListTargets: + """Tests for listing targets.""" + + @staticmethod + @pytest.mark.asyncio + async def test_list_targets( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to get a list of target IDs.""" + id_1 = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + id_2 = await async_vws_client.add_target( + name="a", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + targets = await async_vws_client.list_targets() + assert sorted(targets) == sorted([id_1, id_2]) + + +class TestDelete: + """Test for deleting a target.""" + + @staticmethod + @pytest.mark.asyncio + async def test_delete_target( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to delete a target.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + targets = await async_vws_client.list_targets() + assert target_id in targets + await async_vws_client.delete_target( + target_id=target_id, + ) + targets = await async_vws_client.list_targets() + assert target_id not in targets + + +class TestGetTargetSummaryReport: + """Tests for getting a summary report for a target.""" + + @staticmethod + @pytest.mark.asyncio + async def test_get_target_summary_report( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """Details of a target are returned by + ``get_target_summary_report``. + """ + target_name = uuid.uuid4().hex + target_id = await async_vws_client.add_target( + name=target_name, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + report = await async_vws_client.get_target_summary_report( + target_id=target_id, + ) + + assert report.target_name == target_name + assert report.active_flag is True + assert report.total_recos == 0 + + +class TestGetDatabaseSummaryReport: + """Tests for getting a summary report for a database.""" + + @staticmethod + @pytest.mark.asyncio + async def test_get_target( + async_vws_client: AsyncVWS, + ) -> None: + """Details of a database are returned by + ``get_database_summary_report``. + """ + report = await async_vws_client.get_database_summary_report() + + assert isinstance(report, DatabaseSummaryReport) + assert report.active_images == 0 + + +class TestGetTargetRecord: + """Tests for getting a record of a target.""" + + @staticmethod + @pytest.mark.asyncio + async def test_get_target_record( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """Details of a target are returned by + ``get_target_record``. + """ + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + result = await async_vws_client.get_target_record( + target_id=target_id, + ) + expected_target_record = TargetRecord( + target_id=target_id, + active_flag=True, + name="x", + width=1, + tracking_rating=-1, + reco_rating="", + ) + + assert result.target_record == expected_target_record + assert result.status == TargetStatuses.PROCESSING + + +class TestWaitForTargetProcessed: + """Tests for waiting for a target to be processed.""" + + @staticmethod + @pytest.mark.asyncio + async def test_wait_for_target_processed( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to wait until a target is processed.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + report = await async_vws_client.get_target_summary_report( + target_id=target_id, + ) + assert report.status == TargetStatuses.PROCESSING + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + report = await async_vws_client.get_target_summary_report( + target_id=target_id, + ) + assert report.status != TargetStatuses.PROCESSING + + @staticmethod + @pytest.mark.asyncio + async def test_custom_timeout( + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to set a maximum timeout.""" + with MockVWS(processing_time_seconds=0.5) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + with pytest.raises( + expected_exception=(TargetProcessingTimeoutError), + ): + await async_vws_client.wait_for_target_processed( + target_id=target_id, + timeout_seconds=0.1, + ) + + +class TestGetDuplicateTargets: + """Tests for getting duplicate targets.""" + + @staticmethod + @pytest.mark.asyncio + async def test_get_duplicate_targets( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to get the IDs of similar targets.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + similar_target_id = await async_vws_client.add_target( + name="a", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.wait_for_target_processed( + target_id=similar_target_id, + ) + duplicates = await async_vws_client.get_duplicate_targets( + target_id=target_id, + ) + assert duplicates == [similar_target_id] + + +class TestUpdateTarget: + """Tests for updating a target.""" + + @staticmethod + @pytest.mark.asyncio + async def test_update_target( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, + different_high_quality_image: io.BytesIO, + ) -> None: + """It is possible to update a target.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + [matching_target] = await async_cloud_reco_client.query( + image=image, + ) + assert matching_target.target_id == target_id + query_target_data = matching_target.target_data + assert query_target_data is not None + assert query_target_data.application_metadata is None + + new_name = uuid.uuid4().hex + new_width = 2.0 + new_application_metadata = base64.b64encode( + s=b"a", + ).decode(encoding="ascii") + await async_vws_client.update_target( + target_id=target_id, + name=new_name, + width=new_width, + active_flag=True, + image=different_high_quality_image, + application_metadata=new_application_metadata, + ) + + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + target_details = await async_vws_client.get_target_record( + target_id=target_id, + ) + assert target_details.target_record.name == new_name + assert target_details.target_record.active_flag + + @staticmethod + @pytest.mark.asyncio + async def test_no_fields_given( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + ) -> None: + """It is possible to give no update fields.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.update_target( + target_id=target_id, + ) + + +class _ForbiddenDownloadTransport: + """An async transport which refuses to serve a report, as an expired + URL would. + """ + + async def aclose(self) -> None: + """Close the transport.""" + + async def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a "forbidden" response.""" + del method, headers, request_timeout + body = "AccessDenied" + return Response( + text=body, + url=url, + status_code=HTTPStatus.FORBIDDEN, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(encoding="utf-8"), + ) + + +class TestRecoCountsReport: + """Tests for database reco counts reports.""" + + @staticmethod + @pytest.mark.asyncio + async def test_reco_counts_report( + *, + async_vws_client: AsyncVWS, + report_month: datetime.date, + ) -> None: + """A report can be requested, waited for and downloaded.""" + client = async_vws_client + report_request = await client.request_database_reco_counts_report( + year=report_month.year, + month=calendar.Month(value=report_month.month), + ) + assert report_request.transaction_id + assert report_request.presigned_url + + report = await client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + # No targets have been recognized, so the report has no rows. + assert not report.reco_counts + assert report.raw_csv.startswith(b"target_id,reco_count") + + @staticmethod + @pytest.mark.asyncio + async def test_not_ready(*, current_month: datetime.date) -> None: + """Downloading a report before Vuforia has generated it raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async with AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) as client: + report_request = ( + await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + ) + + with pytest.raises( + expected_exception=RecoCountsReportNotReadyError, + ) as exc: + await client.download_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + + @staticmethod + @pytest.mark.asyncio + async def test_wait_timeout(*, current_month: datetime.date) -> None: + """Waiting for a report which is not generated in time raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async with AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) as client: + report_request = ( + await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + ) + + maximum_wait_seconds = 5 + start_time = time.monotonic() + + with pytest.raises( + expected_exception=RecoCountsReportTimeoutError, + ): + await client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + elapsed_time = time.monotonic() - start_time + assert elapsed_time < maximum_wait_seconds + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize( + argnames=("year", "month"), + argvalues=[ + pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"), + pytest.param( + 1999, + calendar.Month.DECEMBER, + id="year-in-the-past-december", + ), + ], + ) + async def test_month_not_accepted( + *, + async_vws_client: AsyncVWS, + year: int, + month: calendar.Month, + ) -> None: + """Months other than the current and previous month are + rejected. + """ + with pytest.raises(expected_exception=FailError) as exc: + await async_vws_client.request_database_reco_counts_report( + year=year, + month=month, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + + @staticmethod + @pytest.mark.asyncio + async def test_database_id_does_not_match_keys( + *, + current_month: datetime.date, + ) -> None: + """A database ID which does not match the given keys is + rejected. + """ + with MockVWS() as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async with AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=uuid.uuid4().hex, + ) as client: + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + assert ( + exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + ) + + @staticmethod + @pytest.mark.asyncio + async def test_download_error() -> None: + """An error response from the report's URL raises an error.""" + async with AsyncVWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + transport=_ForbiddenDownloadTransport(), + ) as client: + with pytest.raises( + expected_exception=RecoCountsReportDownloadError, + ) as exc: + await client.download_reco_counts_report( + presigned_url="https://example.com/reports/recoCounts/x", + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + @staticmethod + @pytest.mark.asyncio + async def test_no_database_id(*, current_month: datetime.date) -> None: + """A client which was given no database ID cannot request a + report. + """ + async with AsyncVWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + ) as client: + with pytest.raises(expected_exception=DatabaseIdNotSetError): + await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + +class TestGenerateVumarkInstance: + """Tests for generating VuMark instances.""" + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize( + argnames=("accept", "expected_prefix"), + argvalues=[ + pytest.param( + VuMarkAccept.PNG, + b"\x89PNG\r\n\x1a\n", + id="png", + ), + pytest.param( + VuMarkAccept.SVG, + b"<", + id="svg", + ), + pytest.param( + VuMarkAccept.PDF, + b"%PDF", + id="pdf", + ), + ], + ) + async def test_generate_vumark_instance( + *, + async_vumark_service_client: AsyncVuMarkService, + vumark_target_id: str, + accept: VuMarkAccept, + expected_prefix: bytes, + ) -> None: + """The returned bytes match the requested format.""" + result = await async_vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="12345", + accept=accept, + ) + assert result.startswith(expected_prefix) diff --git a/tests/test_async_vws_exceptions.py b/tests/test_async_vws_exceptions.py new file mode 100644 index 000000000..efc3c05ae --- /dev/null +++ b/tests/test_async_vws_exceptions.py @@ -0,0 +1,427 @@ +"""Tests for VWS exceptions raised from async clients.""" + +import io +import uuid +from http import HTTPStatus + +import pytest +from mock_vws import MockVWS, VuMarkGenerationFailure +from mock_vws.database import CloudDatabase +from mock_vws.states import States + +from vws import AsyncVuMarkService, AsyncVWS +from vws.exceptions.base_exceptions import VWSError # noqa: TC001 +from vws.exceptions.custom_exceptions import ( + ServerError, +) +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + AuthorizationFailedError, + BadImageError, + FailError, + ImageTooLargeError, + InvalidInstanceIdError, + LicenseCheckFailedError, + MetadataTooLargeError, + ProjectHasNoAPIAccessError, + ProjectInactiveError, + ProjectSuspendedError, + QuotaExceededError, + RequestQuotaReachedError, + TargetNameExistError, + TargetQuotaReachedError, + TargetStatusProcessingError, + UnknownTargetError, +) +from vws.vumark_accept import VuMarkAccept + + +@pytest.mark.asyncio +async def test_image_too_large( + *, + async_vws_client: AsyncVWS, + png_too_large: io.BytesIO | io.BufferedRandom, +) -> None: + """When giving an image which is too large, an + ``ImageTooLarge`` exception is raised. + """ + with pytest.raises( + expected_exception=ImageTooLargeError, + ) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=png_too_large, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_invalid_given_id( + async_vws_client: AsyncVWS, +) -> None: + """Giving an invalid ID causes an ``UnknownTarget`` + exception to be raised. + """ + target_id = "12345abc" + with pytest.raises( + expected_exception=UnknownTargetError, + ) as exc: + await async_vws_client.delete_target( + target_id=target_id, + ) + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + assert exc.value.target_id == target_id + + +@pytest.mark.asyncio +async def test_add_bad_name( + *, + async_vws_client: AsyncVWS, + high_quality_image: io.BytesIO, +) -> None: + """When a name with a bad character is given, a + ``ServerError`` exception is raised. + """ + max_char_value = 65535 + bad_name = chr(max_char_value + 1) + with pytest.raises( + expected_exception=ServerError, + ) as exc: + await async_vws_client.add_target( + name=bad_name, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.asyncio +async def test_request_quota_reached() -> None: + """A ``RequestQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(request_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=RequestQuotaReachedError) as exc: + await async_vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.asyncio +async def test_target_quota_reached( + high_quality_image: io.BytesIO, +) -> None: + """A ``TargetQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(target_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=TargetQuotaReachedError) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames=("state", "expected_exception"), + argvalues=[ + (States.PROJECT_SUSPENDED, ProjectSuspendedError), + (States.PROJECT_HAS_NO_API_ACCESS, ProjectHasNoAPIAccessError), + ], +) +async def test_project_state_error( + *, + state: States, + expected_exception: type[VWSError], +) -> None: + """Configured project states raise their matching exceptions.""" + database = CloudDatabase(state=state) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=expected_exception) as exc: + await async_vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.asyncio +async def test_fail(high_quality_image: io.BytesIO) -> None: + """A ``Fail`` exception is raised when the server access key + does not exist. + """ + with MockVWS(): + async_vws_client = AsyncVWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + ) + + with pytest.raises( + expected_exception=FailError, + ) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.asyncio +async def test_bad_image( + async_vws_client: AsyncVWS, +) -> None: + """A ``BadImage`` exception is raised when a non-image is + given. + """ + not_an_image = io.BytesIO(initial_bytes=b"Not an image") + with pytest.raises( + expected_exception=BadImageError, + ) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=not_an_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_target_name_exist( + *, + async_vws_client: AsyncVWS, + high_quality_image: io.BytesIO, +) -> None: + """A ``TargetNameExist`` exception is raised after adding + two targets with the same name. + """ + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + with pytest.raises( + expected_exception=TargetNameExistError, + ) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + assert exc.value.target_name == "x" + + +@pytest.mark.asyncio +async def test_project_inactive( + high_quality_image: io.BytesIO, +) -> None: + """A ``ProjectInactive`` exception is raised if adding a + target to an inactive database. + """ + database = CloudDatabase(state=States.PROJECT_INACTIVE) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises( + expected_exception=ProjectInactiveError, + ) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.asyncio +async def test_target_status_processing( + *, + async_vws_client: AsyncVWS, + high_quality_image: io.BytesIO, +) -> None: + """A ``TargetStatusProcessing`` exception is raised if + trying to delete a target which is processing. + """ + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + with pytest.raises( + expected_exception=TargetStatusProcessingError, + ) as exc: + await async_vws_client.delete_target( + target_id=target_id, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + assert exc.value.target_id == target_id + + +@pytest.mark.asyncio +async def test_metadata_too_large( + *, + async_vws_client: AsyncVWS, + high_quality_image: io.BytesIO, +) -> None: + """A ``MetadataTooLarge`` exception is raised if the metadata + given is too large. + """ + with pytest.raises( + expected_exception=MetadataTooLargeError, + ) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata="a" * 1024 * 1024 * 10, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_authentication_failure( + high_quality_image: io.BytesIO, +) -> None: + """An ``AuthenticationFailure`` exception is raised when the + server secret key is incorrect. + """ + database = CloudDatabase() + + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=uuid.uuid4().hex, + ) + + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_invalid_instance_id( + *, + async_vumark_service_client: AsyncVuMarkService, + vumark_target_id: str, +) -> None: + """An ``InvalidInstanceId`` exception is raised when an + empty instance ID is given. + """ + with pytest.raises( + expected_exception=InvalidInstanceIdError, + ) as exc: + await async_vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="", + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames=("failure", "exception_type", "status_code"), + argvalues=[ + ( + VuMarkGenerationFailure.QUOTA_EXCEEDED, + QuotaExceededError, + HTTPStatus.FORBIDDEN, + ), + ( + VuMarkGenerationFailure.LICENSE_CHECK_FAILED, + LicenseCheckFailedError, + HTTPStatus.FORBIDDEN, + ), + ( + VuMarkGenerationFailure.AUTHORIZATION_FAILED, + AuthorizationFailedError, + HTTPStatus.UNAUTHORIZED, + ), + ], +) +async def test_documented_vumark_error_codes( + *, + failure: VuMarkGenerationFailure, + exception_type: type[VWSError], + status_code: HTTPStatus, +) -> None: + """Documented VuMark failures raise matching exceptions.""" + with MockVWS(vumark_generation_failure=failure): + vumark_service = AsyncVuMarkService( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + ) + with pytest.raises(expected_exception=exception_type) as exc: + await vumark_service.generate_vumark_instance( + target_id="exampletargetid", + instance_id="example_instance_id", + accept=VuMarkAccept.PNG, + ) + await vumark_service.aclose() + + assert exc.value.response.status_code == status_code + assert failure.value in exc.value.response.text diff --git a/tests/test_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py index 518317a8f..29632000c 100644 --- a/tests/test_cloud_reco_exceptions.py +++ b/tests/test_cloud_reco_exceptions.py @@ -1,30 +1,31 @@ -""" -Tests for exceptions raised when using the CloudRecoService. -""" +"""Tests for exceptions raised when using the CloudRecoService.""" -import io +import io # noqa: TC003 +import json +import uuid from http import HTTPStatus import pytest -import requests -from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws import CloudQueryFailureResponse, MockVWS +from mock_vws.database import CloudDatabase from mock_vws.states import States -from vws import VWS, CloudRecoService -from vws.exceptions.base_exceptions import CloudRecoException +from vws import CloudRecoService +from vws.exceptions.base_exceptions import CloudRecoError from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailure, - InactiveProject, - MatchProcessing, - MaxNumResultsOutOfRange, + AuthenticationFailureError, + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, + RequestTimeTooSkewedError, ) from vws.exceptions.custom_exceptions import ( - ConnectionErrorPossiblyImageTooLarge, + RequestEntityTooLargeError, ) def test_too_many_max_results( + *, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, ) -> None: @@ -32,7 +33,7 @@ def test_too_many_max_results( A ``MaxNumResultsOutOfRange`` error is raised if the given ``max_num_results`` is out of range. """ - with pytest.raises(MaxNumResultsOutOfRange) as exc: + with pytest.raises(expected_exception=MaxNumResultsOutOfRangeError) as exc: cloud_reco_client.query( image=high_quality_image, max_num_results=51, @@ -40,113 +41,159 @@ def test_too_many_max_results( expected_value = ( "Integer out of range (51) in form data part 'max_result'. " - 'Accepted range is from 1 to 50 (inclusive).' + "Accepted range is from 1 to 50 (inclusive)." ) - assert str(exc.value) == exc.value.response.text == expected_value + assert str(object=exc.value) == exc.value.response.text == expected_value def test_image_too_large( + *, cloud_reco_client: CloudRecoService, - png_too_large: io.BytesIO, + png_too_large: io.BytesIO | io.BufferedRandom, ) -> None: """ - A ``ConnectionErrorPossiblyImageTooLarge`` exception is raised if an - image which is too large is given. + A ``RequestEntityTooLarge`` exception is raised if an image which is + too + large is given. """ - with pytest.raises(ConnectionErrorPossiblyImageTooLarge) as exc: + with pytest.raises(expected_exception=RequestEntityTooLargeError) as exc: cloud_reco_client.query(image=png_too_large) - assert isinstance(exc.value, requests.ConnectionError) + assert ( + exc.value.response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + ) def test_cloudrecoexception_inheritance() -> None: - """ - CloudRecoService-specific exceptions inherit from CloudRecoException. + """CloudRecoService-specific exceptions inherit from + CloudRecoException. """ subclasses = [ - MatchProcessing, - MaxNumResultsOutOfRange, + MaxNumResultsOutOfRangeError, + InactiveProjectError, + BadImageError, + AuthenticationFailureError, + RequestTimeTooSkewedError, ] for subclass in subclasses: - assert issubclass(subclass, CloudRecoException) - - -def test_base_exception( - vws_client: VWS, - cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, -) -> None: - """ - ``CloudRecoException``s has a response property. - """ - vws_client.add_target( - name='x', - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) + assert issubclass(subclass, CloudRecoError) - with pytest.raises(CloudRecoException) as exc: - cloud_reco_client.query(image=high_quality_image) - assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - - -def test_match_processing( - vws_client: VWS, - cloud_reco_client: CloudRecoService, +def test_authentication_failure( high_quality_image: io.BytesIO, ) -> None: """ - A ``MatchProcessing`` exception is raised when a target in processing is - matched. - """ - vws_client.add_target( - name='x', - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - with pytest.raises(MatchProcessing) as exc: - cloud_reco_client.query(image=high_quality_image) - assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - - -def test_authentication_failure(high_quality_image: io.BytesIO) -> None: - """ - An ``AuthenticationFailure`` exception is raised when the client access key + An ``AuthenticationFailure`` exception is raised when the client + access + key exists but the client secret key is incorrect. """ - database = VuforiaDatabase() + database = CloudDatabase() cloud_reco_client = CloudRecoService( client_access_key=database.client_access_key, - client_secret_key='a', + client_secret_key=uuid.uuid4().hex, ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) - with pytest.raises(AuthenticationFailure) as exc: + with pytest.raises( + expected_exception=AuthenticationFailureError + ) as exc: cloud_reco_client.query(image=high_quality_image) assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED -def test_inactive_project(high_quality_image: io.BytesIO) -> None: +def test_inactive_project( + high_quality_image: io.BytesIO, +) -> None: """ An ``InactiveProject`` exception is raised when querying an inactive database. """ - database = VuforiaDatabase(state=States.PROJECT_INACTIVE) + database = CloudDatabase(state=States.PROJECT_INACTIVE) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) cloud_reco_client = CloudRecoService( client_access_key=database.client_access_key, client_secret_key=database.client_secret_key, ) - with pytest.raises(InactiveProject) as exc: + with pytest.raises(expected_exception=InactiveProjectError) as exc: cloud_reco_client.query(image=high_quality_image) - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + response = exc.value.response + assert response.status_code == HTTPStatus.FORBIDDEN + # We need one test which checks tell position + # and so we choose this one almost at random. + assert response.tell_position != 0 + + +@pytest.mark.parametrize( + argnames=("body", "headers"), + argvalues=[ + ("", {"X-Query-Failure": "empty"}), + ( + "Arbitrary upstream failure", + { + "Content-Type": "application/json", + "X-Query-Failure": "text", + }, + ), + ], + ids=["empty", "arbitrary-text"], +) +def test_non_json_client_error( + *, + high_quality_image: io.BytesIO, + body: str, + headers: dict[str, str], +) -> None: + """Non-JSON 4xx responses raise a response-carrying error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.BAD_REQUEST, + headers=headers, + body=body, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=CloudRecoError) as exc: + cloud_reco_client.query(image=high_quality_image) + + response = exc.value.response + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.text == body + assert response.content == body.encode() + response_headers = { + key.lower(): value for key, value in response.headers.items() + } + assert response_headers["x-query-failure"] == headers["X-Query-Failure"] + assert response.request_body + + +def test_non_json_success_response( + *, + high_quality_image: io.BytesIO, +) -> None: + """Malformed successful responses retain the JSON parsing error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.OK, + headers={"Content-Type": "application/json"}, + body="Not JSON", + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=json.JSONDecodeError): + cloud_reco_client.query(image=high_quality_image) diff --git a/tests/test_meta.py b/tests/test_meta.py deleted file mode 100644 index 3c6b02493..000000000 --- a/tests/test_meta.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Tests for this repository. -""" - -from pathlib import Path - - -def test_init_files() -> None: - """ - ``__init__`` files exist where they should do. - - If ``__init__`` files are missing, linters may not run on all files that - they should run on. - """ - directories = (Path('src'), Path('tests')) - - for directory in directories: - files = directory.glob('**/*.py') - for python_file in files: - parent = python_file.parent - expected_init = parent / '__init__.py' - assert expected_init.exists() diff --git a/tests/test_model_targets.py b/tests/test_model_targets.py new file mode 100644 index 000000000..4ee13db6e --- /dev/null +++ b/tests/test_model_targets.py @@ -0,0 +1,802 @@ +"""Tests for the Model Target Web API client.""" + +import io +import json +import uuid +import zipfile +from http import HTTPStatus + +import pytest +from beartype import beartype +from freezegun import freeze_time +from mock_vws import ( + MockVWS, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) + +from vws import ModelTargetService +from vws.exceptions.model_target_exceptions import ( + ModelTargetAuthenticationError, + ModelTargetDatasetNotDoneError, + ModelTargetDatasetTimeoutError, + ModelTargetError, + ModelTargetOAuth2Error, + ModelTargetValidationError, + UnknownModelTargetDatasetError, +) +from vws.model_target_datasets import ( + CadDataFormat, + GuideViewPosition, + ModelTargetDatasetType, + ModelTargetModel, + ModelTargetView, + RealisticAppearance, +) +from vws.reports import ModelTargetDatasetStatuses +from vws.response import Response +from vws.transports import RequestsTransport, Transport + +# The mock accepts one hard-coded pair of Model Target Web API OAuth2 +# credentials, which it does not expose. +_CLIENT_ID = "client-id" +_CLIENT_SECRET = "client-secret" # noqa: S105 + +_DATASET_TYPES = [ + ModelTargetDatasetType.STANDARD, + ModelTargetDatasetType.ADVANCED, +] + + +@beartype +def _response(*, text: str) -> Response: + """Get a response with a given body. + + Args: + text: The body of the response. + + Returns: + A response with the given body. + """ + content = text.encode(encoding="utf-8") + return Response( + text=text, + url="https://vws.vuforia.com/modeltargets/datasets", + status_code=HTTPStatus.BAD_REQUEST, + headers={}, + request_body=None, + tell_position=len(content), + content=content, + ) + + +@beartype +class _CountingTransport: + """A transport which counts the requests made to each path.""" + + def __init__(self, *, transport: Transport) -> None: + """ + Args: + transport: The transport to make requests with. + """ + self._transport = transport + self.urls: list[str] = [] + + def close(self) -> None: + """Close the wrapped transport.""" + self._transport.close() + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make a request, recording the URL. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the HTTP response. + """ + self.urls.append(url) + return self._transport( + method=method, + url=url, + headers=headers, + data=data, + request_timeout=request_timeout, + ) + + +@beartype +class _BadTokenTransport: + """A transport which replaces each bearer token with an invalid + one. + """ + + def __init__(self, *, transport: Transport) -> None: + """ + Args: + transport: The transport to make requests with. + """ + self._transport = transport + + def close(self) -> None: + """Close the wrapped transport.""" + self._transport.close() + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make a request with an invalid bearer token. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the HTTP response. + """ + given_headers = dict(headers) + authorization = given_headers.get("Authorization", "") + if authorization.startswith("Bearer "): + given_headers["Authorization"] = "Bearer not-a-json-web-token" + + return self._transport( + method=method, + url=url, + headers=given_headers, + data=data, + request_timeout=request_timeout, + ) + + +class TestAccessToken: + """Tests for getting an access token.""" + + @staticmethod + @pytest.mark.usefixtures("_mock_model_targets") + def test_token_is_a_bearer_token() -> None: + """An access token is given for valid credentials.""" + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) + + assert client.get_access_token() + + @staticmethod + @pytest.mark.usefixtures("_mock_model_targets") + def test_token_is_reused( + *, + model_target_model: ModelTargetModel, + ) -> None: + """One access token is used for multiple requests.""" + transport = _CountingTransport(transport=RequestsTransport()) + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + transport=transport, + ) + + for _ in range(2): + client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + token_urls = [url for url in transport.urls if "oauth2" in url] + assert len(token_urls) == 1 + transport.close() + + @staticmethod + @pytest.mark.usefixtures("_mock_model_targets") + def test_expired_token_is_replaced( + *, + model_target_model: ModelTargetModel, + ) -> None: + """A new access token is requested once the old one expires.""" + transport = _CountingTransport(transport=RequestsTransport()) + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + transport=transport, + ) + + with freeze_time(time_to_freeze="2026-01-01") as frozen_time: + client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + # Mock tokens last an hour. + frozen_time.tick(delta=60 * 60 + 1) + client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + token_urls = [url for url in transport.urls if "oauth2" in url] + expected_token_request_count = 2 + assert len(token_urls) == expected_token_request_count + + @staticmethod + @pytest.mark.usefixtures("_mock_model_targets") + def test_invalid_credentials() -> None: + """An exception is raised when the credentials are not known.""" + client = ModelTargetService( + client_id="not-a-client-id", + client_secret="not-a-client-secret", # noqa: S106 + ) + + with pytest.raises( + expected_exception=ModelTargetOAuth2Error, + ) as exc: + client.get_access_token() + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + assert exc.value.error == "invalid_client" + assert not exc.value.error_description + + @staticmethod + @pytest.mark.usefixtures("_mock_model_targets") + def test_invalid_bearer_token( + *, + model_target_model: ModelTargetModel, + ) -> None: + """An exception is raised when the bearer token is not + accepted. + """ + transport = _BadTokenTransport(transport=RequestsTransport()) + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + transport=transport, + ) + + with pytest.raises( + expected_exception=ModelTargetAuthenticationError, + ) as exc: + client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + assert exc.value.target == "jwt" + assert exc.value.message + transport.close() + + +class TestDatasetLifecycle: + """Tests for the dataset lifecycle.""" + + @staticmethod + @pytest.mark.parametrize( + argnames="dataset_type", + argvalues=_DATASET_TYPES, + ) + def test_create_wait_download_delete( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, + dataset_type: ModelTargetDatasetType, + ) -> None: + """A dataset can be created, downloaded and then deleted.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=dataset_type, + ) + + report = model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.dataset_uuid == dataset_uuid + assert report.completed_at is not None + assert report.completed_at >= report.created_at + assert report.eta is None + assert report.error is None + assert report.warning is None + + dataset = model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=dataset) + ) as archive: + dataset_json = json.loads(s=archive.read(name="dataset.json")) + + assert dataset_json["uuid"] == dataset_uuid + assert dataset_json["type"] == dataset_type.value + + model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + @staticmethod + def test_status_while_processing( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """A processing dataset has an estimated completion time.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.PROCESSING + assert report.eta is not None + assert report.eta >= report.created_at + assert report.completed_at is None + + @staticmethod + def test_download_while_processing( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset cannot be downloaded before it is generated.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetNotDoneError, + ) as exc: + model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert ( + exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + ) + assert exc.value.code == "UNSUPPORTED_STATE" + assert exc.value.target == dataset_uuid + + @staticmethod + def test_dataset_types_are_separate( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset is not visible to requests for the other type.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + @staticmethod + def test_advanced_dataset_takes_multiple_models( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """An advanced dataset can be generated from multiple models.""" + other_model = ModelTargetModel( + name="other-model", + cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", + cad_data_format=CadDataFormat.GLB, + realistic_appearance=RealisticAppearance.TRUE, + ) + + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model, other_model], + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + assert dataset_uuid + + @staticmethod + def test_state_based_model( + *, + model_target_client: ModelTargetService, + ) -> None: + """A State-Based Model Target dataset can be created.""" + configuration = json.dumps(obj={"states": {"open": {}, "closed": {}}}) + model = ModelTargetModel( + name="model", + cad_data_url="https://example.com/model.zip", + cad_data_format=CadDataFormat.ZIP, + state_based_configuration_json_string=configuration, + views=[ + ModelTargetView( + name="front", + guide_view_position=GuideViewPosition( + rotation=[0.0, 0.0, 0.0, 1.0], + translation=[0.0, 0.0, 1.0], + ), + states=["open"], + ), + ], + ) + + assert model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +class TestUnknownDataset: + """Tests for requests for datasets which do not exist.""" + + @staticmethod + def test_get_status(*, model_target_client: ModelTargetService) -> None: + """An exception is raised for an unknown dataset.""" + dataset_uuid = uuid.uuid4().hex + with pytest.raises( + expected_exception=UnknownModelTargetDatasetError, + ) as exc: + model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + assert exc.value.code == "NOT_FOUND" + assert dataset_uuid in exc.value.message + + @staticmethod + def test_download(*, model_target_client: ModelTargetService) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + model_target_client.download_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @staticmethod + def test_delete(*, model_target_client: ModelTargetService) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + model_target_client.delete_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +class TestValidation: + """Tests for requests which Vuforia rejects.""" + + @staticmethod + def test_no_cad_data( + *, + model_target_client: ModelTargetService, + ) -> None: + """A model needs exactly one CAD data source.""" + with pytest.raises( + expected_exception=ModelTargetValidationError, + ) as exc: + model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[ModelTargetModel(name="model")], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + assert exc.value.code == "BAD_REQUEST" + (detail,) = exc.value.details + assert detail.code == "VALIDATION_ERROR" + assert "cadDataUrl" in detail.message + + @staticmethod + def test_two_cad_data_sources( + *, + model_target_client: ModelTargetService, + ) -> None: + """A model cannot give two CAD data sources.""" + model = ModelTargetModel( + name="model", + cad_data_url="https://example.com/model.zip", + cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", + ) + + with pytest.raises(expected_exception=ModelTargetValidationError): + model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @staticmethod + def test_two_models_in_a_standard_dataset( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, + ) -> None: + """A standard dataset takes exactly one model.""" + with pytest.raises( + expected_exception=ModelTargetValidationError, + ) as exc: + model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model, model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + (detail,) = exc.value.details + assert detail.message == "exactly one model should be provided" + + +class TestGenerationResult: + """Tests for datasets which Vuforia does not generate cleanly.""" + + @staticmethod + def test_generation_failure( + *, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset which fails to generate reports the failure.""" + message = "Model Target dataset generation failed" + failure = ModelTargetGenerationFailure(message=message) + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_failure=failure, + ): + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.FAILED + assert report.error is not None + assert report.error.message == message + assert report.warning is None + + with pytest.raises( + expected_exception=ModelTargetDatasetNotDoneError, + ): + client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @staticmethod + def test_generation_warning( + *, + model_target_model: ModelTargetModel, + ) -> None: + """A dataset which generates with a warning reports the + warning. + """ + warning = ModelTargetGenerationWarning() + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_warning=warning, + ): + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.error is None + assert report.warning is not None + assert report.warning.message == warning.message + assert report.warning.target == dataset_uuid + (detail,) = report.warning.details + assert detail.code == "LOW_RECOGNITION_QUALITY" + + assert client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +class TestWaitForDatasetGenerated: + """Tests for waiting for a dataset to be generated.""" + + @staticmethod + def test_timeout(*, model_target_model: ModelTargetModel) -> None: + """An exception is raised when the wait times out.""" + with MockVWS(processing_time_seconds=60): + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetTimeoutError, + ): + client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + +class TestErrorEnvelope: + """Tests for reading responses which are not shaped like Model Target + Web API errors. + """ + + @staticmethod + @pytest.mark.parametrize( + argnames="text", + argvalues=[ + "", + "Not JSON", + "[]", + "{}", + '{"error": "not-an-object"}', + '{"transaction_id": "abc", "result_code": "Fail"}', + ], + ) + def test_unknown_error_shape(*, text: str) -> None: + """An error without a Model Target error object gives empty + values. + """ + error = ModelTargetError(response=_response(text=text)) + + assert not error.code + assert not error.message + assert not error.target + assert not error.details + + @staticmethod + def test_error_without_details() -> None: + """An error which gives no details has no details.""" + text = json.dumps(obj={"error": {"code": "ERROR", "message": "No"}}) + error = ModelTargetError(response=_response(text=text)) + + assert error.code == "ERROR" + assert error.message == "No" + assert not error.target + assert not error.details + + @staticmethod + @pytest.mark.parametrize( + argnames="text", + argvalues=["Not JSON", "[]", "{}"], + ) + def test_unknown_oauth2_error_shape(*, text: str) -> None: + """An OAuth2 error without an error code gives empty values.""" + error = ModelTargetOAuth2Error(response=_response(text=text)) + + assert not error.error + assert not error.error_description + + @staticmethod + def test_oauth2_error_description() -> None: + """An OAuth2 error description is given when Vuforia gives one.""" + description = "Missing or invalid authorization header" + text = json.dumps( + obj={ + "error": "invalid_request", + "error_description": description, + }, + ) + error = ModelTargetOAuth2Error(response=_response(text=text)) + + assert error.error == "invalid_request" + assert error.error_description == description + + +class TestBaseVWSURL: + """Tests for using a custom base URL.""" + + @staticmethod + def test_custom_base_url( + *, + model_target_model: ModelTargetModel, + ) -> None: + """The Model Target Web API can be served from a URL with a + path. + """ + base_vws_url = "https://example.com/vws" + with MockVWS( + base_vws_url=base_vws_url, + processing_time_seconds=0.2, + ): + client = ModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + base_vws_url=base_vws_url, + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.dataset_uuid == dataset_uuid diff --git a/tests/test_query.py b/tests/test_query.py index b180abff7..dac206b25 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,77 +1,178 @@ -""" -Tests for the ``CloudRecoService`` querying functionality. -""" +"""Tests for the ``CloudRecoService`` querying functionality.""" -import io +import datetime +import io # noqa: TC003 import uuid +from typing import BinaryIO +import pytest +import requests +from freezegun import freeze_time from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from vws import VWS, CloudRecoService from vws.include_target_data import CloudRecoIncludeTargetData class TestQuery: - """ - Tests for making image queries. - """ + """Tests for making image queries.""" + @staticmethod def test_no_matches( - self, + *, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - An empty list is returned if there are no matches. - """ - result = cloud_reco_client.query(image=high_quality_image) + """An empty list is returned if there are no matches.""" + result = cloud_reco_client.query(image=image) assert result == [] + @staticmethod def test_match( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - Details of matching targets are returned. - """ + """Details of matching targets are returned.""" target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) - [matching_target] = cloud_reco_client.query(image=high_quality_image) + [matching_target] = cloud_reco_client.query(image=image) assert matching_target.target_id == target_id +class TestDefaultRequestTimeout: + """Tests for the default request timeout.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=("response_delay_seconds", "expect_timeout"), + argvalues=[(29, False), (31, True)], + ) + def test_default_timeout( + *, + image: io.BytesIO | BinaryIO, + response_delay_seconds: int, + expect_timeout: bool, + ) -> None: + """At 29 seconds there is no error; at 31 seconds there is a + timeout. + """ + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + cloud_reco_client.query(image=image) + else: + matches = cloud_reco_client.query(image=image) + assert not matches + + +class TestCustomRequestTimeout: + """Tests for custom request timeout values.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=( + "custom_timeout", + "response_delay_seconds", + "expect_timeout", + ), + argvalues=[ + (0.1, 0.09, False), + (0.1, 0.11, True), + ((5.0, 0.1), 0.09, False), + ((5.0, 0.1), 0.11, True), + ], + ) + def test_custom_timeout( + *, + image: io.BytesIO | BinaryIO, + custom_timeout: float | tuple[float, float], + response_delay_seconds: float, + expect_timeout: bool, + ) -> None: + """Custom timeouts are honored for both float and tuple forms.""" + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + request_timeout_seconds=custom_timeout, + ) + + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + cloud_reco_client.query(image=image) + else: + matches = cloud_reco_client.query(image=image) + assert not matches + + class TestCustomBaseVWQURL: - """ - Tests for using a custom base VWQ URL. - """ + """Tests for using a custom base VWQ URL.""" - def test_custom_base_url(self, high_quality_image: io.BytesIO) -> None: + @staticmethod + def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: """ - It is possible to use query a target to a database under a custom VWQ + It is possible to use query a target to a database under a + custom + VWQ URL. """ - base_vwq_url = 'http://example.com' + base_vwq_url = "http://example.com" with MockVWS(base_vwq_url=base_vwq_url) as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, ) target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -84,195 +185,228 @@ def test_custom_base_url(self, high_quality_image: io.BytesIO) -> None: base_vwq_url=base_vwq_url, ) - matches = cloud_reco_client.query(image=high_quality_image) + matches = cloud_reco_client.query(image=image) assert len(matches) == 1 match = matches[0] assert match.target_id == target_id + @staticmethod + def test_custom_base_url_with_path_prefix( + image: io.BytesIO | BinaryIO, + ) -> None: + """ + A base VWQ URL with a path prefix is used as-is, without the + prefix being dropped. + """ + base_vwq_url = "http://example.com/prefix" + with MockVWS(base_vwq_url=base_vwq_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + base_vwq_url=base_vwq_url, + ) + + matches = cloud_reco_client.query(image=image) + assert len(matches) == 1 + class TestMaxNumResults: - """ - Tests for the ``max_num_results`` parameter of ``query``. - """ + """Tests for the ``max_num_results`` parameter of ``query``.""" + @staticmethod def test_default( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - By default the maximum number of results is 1. - """ + """By default the maximum number of results is 1.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) target_id_2 = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.wait_for_target_processed(target_id=target_id_2) - matches = cloud_reco_client.query(image=high_quality_image) + matches = cloud_reco_client.query(image=image) assert len(matches) == 1 + @staticmethod def test_custom( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - It is possible to set a custom ``max_num_results``. - """ + """It is possible to set a custom ``max_num_results``.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) target_id_2 = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) target_id_3 = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.wait_for_target_processed(target_id=target_id_2) vws_client.wait_for_target_processed(target_id=target_id_3) + max_num_results = 2 matches = cloud_reco_client.query( - image=high_quality_image, - max_num_results=2, + image=image, + max_num_results=max_num_results, ) - assert len(matches) == 2 + assert len(matches) == max_num_results class TestIncludeTargetData: - """ - Tests for the ``include_target_data`` parameter of ``query``. - """ + """Tests for the ``include_target_data`` parameter of ``query``.""" + @staticmethod def test_default( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - By default, target data is only returned in the top match. - """ + """By default, target data is only returned in the top match.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) target_id_2 = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.wait_for_target_processed(target_id=target_id_2) top_match, second_match = cloud_reco_client.query( - image=high_quality_image, + image=image, max_num_results=2, ) assert top_match.target_data is not None assert second_match.target_data is None + @staticmethod def test_top( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ - When ``CloudRecoIncludeTargetData.TOP`` is given, target data is only + When ``CloudRecoIncludeTargetData.TOP`` is given, target data is + only returned in the top match. """ target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) target_id_2 = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.wait_for_target_processed(target_id=target_id_2) top_match, second_match = cloud_reco_client.query( - image=high_quality_image, + image=image, max_num_results=2, include_target_data=CloudRecoIncludeTargetData.TOP, ) assert top_match.target_data is not None assert second_match.target_data is None + @staticmethod def test_none( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ - When ``CloudRecoIncludeTargetData.NONE`` is given, target data is not + When ``CloudRecoIncludeTargetData.NONE`` is given, target data + is + not returned in any match. """ target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) target_id_2 = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.wait_for_target_processed(target_id=target_id_2) top_match, second_match = cloud_reco_client.query( - image=high_quality_image, + image=image, max_num_results=2, include_target_data=CloudRecoIncludeTargetData.NONE, ) assert top_match.target_data is None assert second_match.target_data is None + @staticmethod def test_all( - self, + *, vws_client: VWS, cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ When ``CloudRecoIncludeTargetData.ALL`` is given, target data is @@ -281,21 +415,21 @@ def test_all( target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) target_id_2 = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.wait_for_target_processed(target_id=target_id_2) top_match, second_match = cloud_reco_client.query( - image=high_quality_image, + image=image, max_num_results=2, include_target_data=CloudRecoIncludeTargetData.ALL, ) diff --git a/tests/test_transports.py b/tests/test_transports.py new file mode 100644 index 000000000..c039ea7a1 --- /dev/null +++ b/tests/test_transports.py @@ -0,0 +1,407 @@ +"""Tests for HTTP transport implementations.""" + +import io # noqa: TC003 +import uuid +from http import HTTPStatus + +import httpx +import pytest +import respx + +from vws import ( + VWS, + AsyncCloudRecoService, + AsyncVuMarkService, + AsyncVWS, + CloudRecoService, + VuMarkService, +) +from vws.response import Response +from vws.transports import AsyncHTTPXTransport, HTTPXTransport +from vws.vumark_accept import VuMarkAccept + + +class TestHTTPXTransport: + """Tests for ``HTTPXTransport``.""" + + @staticmethod + @respx.mock + def test_float_timeout() -> None: + """``HTTPXTransport`` works with a float timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = HTTPXTransport() + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.tell_position == len(b"OK") + + @staticmethod + @respx.mock + def test_tuple_timeout() -> None: + """``HTTPXTransport`` works with a (connect, read) timeout + tuple. + """ + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = HTTPXTransport() + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + @respx.mock + def test_int_timeout() -> None: + """``HTTPXTransport`` works with an int timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = HTTPXTransport() + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + @respx.mock + def test_context_manager() -> None: + """``HTTPXTransport`` can be used as a context manager.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + with HTTPXTransport() as transport: + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +class TestAsyncHTTPXTransport: + """Tests for ``AsyncHTTPXTransport``.""" + + @staticmethod + @pytest.mark.asyncio + @respx.mock + async def test_float_timeout() -> None: + """``AsyncHTTPXTransport`` works with a float timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = AsyncHTTPXTransport() + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.tell_position == len(b"OK") + + @staticmethod + @pytest.mark.asyncio + @respx.mock + async def test_tuple_timeout() -> None: + """``AsyncHTTPXTransport`` works with a (connect, read) + timeout tuple. + """ + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = AsyncHTTPXTransport() + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + @pytest.mark.asyncio + @respx.mock + async def test_int_timeout() -> None: + """``AsyncHTTPXTransport`` works with an int timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = AsyncHTTPXTransport() + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + @pytest.mark.asyncio + @respx.mock + async def test_context_manager() -> None: + """``AsyncHTTPXTransport`` can be used as an async context + manager. + """ + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + async with AsyncHTTPXTransport() as transport: + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +class _FalsyTransport: + """A sync transport that is falsy but protocol-conforming.""" + + def __bool__(self) -> bool: + """Return ``False`` so truthiness checks would skip this + transport. + """ + return False + + def close(self) -> None: + """Close the transport.""" + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a successful API response for the requested URL.""" + del method, headers, request_timeout + if url.endswith("/query"): + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + if "/instances" in url: + content = b"vumark-bytes" + return Response( + text="", + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=content, + ) + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + + +class _FalsyAsyncTransport: + """An async transport that is falsy but protocol-conforming.""" + + def __bool__(self) -> bool: + """Return ``False`` so truthiness checks would skip this + transport. + """ + return False + + async def aclose(self) -> None: + """Close the transport.""" + + async def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a successful API response for the requested URL.""" + del method, headers, request_timeout + if url.endswith("/query"): + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + if "/instances" in url: + content = b"vumark-bytes" + return Response( + text="", + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=content, + ) + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + + +def test_falsy_sync_transport_is_retained( + high_quality_image: io.BytesIO, +) -> None: + """Falsy custom sync transports are not replaced by the default.""" + access_key = uuid.uuid4().hex + secret_key = uuid.uuid4().hex + transport = _FalsyTransport() + assert not transport + + targets = VWS( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ).list_targets() + assert not targets + + query_results = CloudRecoService( + client_access_key=access_key, + client_secret_key=secret_key, + transport=transport, + ).query(image=high_quality_image) + assert not query_results + + vumark_bytes = VuMarkService( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ).generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + assert vumark_bytes == b"vumark-bytes" + + +@pytest.mark.asyncio +async def test_falsy_async_transport_is_retained( + high_quality_image: io.BytesIO, +) -> None: + """Falsy custom async transports are not replaced by the default.""" + access_key = uuid.uuid4().hex + secret_key = uuid.uuid4().hex + transport = _FalsyAsyncTransport() + assert not transport + + async with AsyncVWS( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ) as vws_client: + assert not await vws_client.list_targets() + + async with AsyncCloudRecoService( + client_access_key=access_key, + client_secret_key=secret_key, + transport=transport, + ) as cloud_reco_client: + assert not await cloud_reco_client.query(image=high_quality_image) + + async with AsyncVuMarkService( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ) as vumark_client: + assert ( + await vumark_client.generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + == b"vumark-bytes" + ) diff --git a/tests/test_vws.py b/tests/test_vws.py index 219eca88f..db6d05fd8 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -1,59 +1,75 @@ -""" -Tests for helper functions for managing a Vuforia database. -""" +"""Tests for helper functions for managing a Vuforia database.""" import base64 +import calendar import datetime -import io -import random +import io # noqa: TC003 +import secrets +import time import uuid -from typing import Optional +from http import HTTPStatus +from typing import BinaryIO import pytest +import requests from freezegun import freeze_time from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase -from vws import VWS, CloudRecoService -from vws.exceptions.custom_exceptions import TargetProcessingTimeout +from vws import VWS, CloudRecoService, VuMarkService +from vws.exceptions.custom_exceptions import ( + DatabaseIdNotSetError, + RecoCountsReportDownloadError, + RecoCountsReportNotReadyError, + RecoCountsReportTimeoutError, + TargetProcessingTimeoutError, +) +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + FailError, +) from vws.reports import ( DatabaseSummaryReport, + RecoCount, + RecoCountsReport, TargetRecord, TargetStatuses, TargetSummaryReport, ) +from vws.response import Response +from vws.vumark_accept import VuMarkAccept class TestAddTarget: - """ - Tests for adding a target. - """ + """Tests for adding a target.""" - @pytest.mark.parametrize('application_metadata', [None, b'a']) - @pytest.mark.parametrize('active_flag', [True, False]) + @staticmethod + @pytest.mark.parametrize( + argnames="application_metadata", + argvalues=[None, b"a"], + ) + @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_add_target( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, - active_flag: bool, - application_metadata: Optional[bytes], + image: io.BytesIO | BinaryIO, + application_metadata: bytes | None, cloud_reco_client: CloudRecoService, + active_flag: bool, ) -> None: - """ - No exception is raised when adding one target. - """ - name = 'x' + """No exception is raised when adding one target.""" + name = "x" width = 1 if application_metadata is None: encoded_metadata = None else: - encoded_metadata_bytes = base64.b64encode(application_metadata) - encoded_metadata = encoded_metadata_bytes.decode('utf-8') + encoded_metadata_bytes = base64.b64encode(s=application_metadata) + encoded_metadata = encoded_metadata_bytes.decode(encoding="utf-8") target_id = vws_client.add_target( name=name, width=width, - image=high_quality_image, + image=image, application_metadata=encoded_metadata, active_flag=active_flag, ) @@ -64,7 +80,7 @@ def test_add_target( assert target_record.width == width assert target_record.active_flag is active_flag vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=high_quality_image) + matching_targets = cloud_reco_client.query(image=image) if active_flag: [matching_target] = matching_targets assert matching_target.target_id == target_id @@ -74,40 +90,164 @@ def test_add_target( else: assert matching_targets == [] + @staticmethod def test_add_two_targets( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - No exception is raised when adding two targets with different names. + """No exception is raised when adding two targets with different + names. This demonstrates that the image seek position is not changed. """ - for name in ('a', 'b'): + for name in ("a", "b"): vws_client.add_target( name=name, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) +class TestDefaultRequestTimeout: + """Tests for the default request timeout.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=("response_delay_seconds", "expect_timeout"), + argvalues=[(29, False), (31, True)], + ) + def test_default_timeout( + *, + image: io.BytesIO | BinaryIO, + response_delay_seconds: int, + expect_timeout: bool, + ) -> None: + """At 29 seconds there is no error; at 31 seconds there is a + timeout. + """ + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + else: + vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +class TestCustomRequestTimeout: + """Tests for custom request timeout values.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=( + "custom_timeout", + "response_delay_seconds", + "expect_timeout", + ), + argvalues=[ + (0.1, 0.09, False), + (0.1, 0.11, True), + ((5.0, 0.1), 0.09, False), + ((5.0, 0.1), 0.11, True), + ], + ) + def test_custom_timeout( + *, + image: io.BytesIO | BinaryIO, + custom_timeout: float | tuple[float, float], + response_delay_seconds: float, + expect_timeout: bool, + ) -> None: + """Custom timeouts are honored for both float and tuple forms.""" + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=custom_timeout, + ) + + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + else: + vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + class TestCustomBaseVWSURL: - """ - Tests for using a custom base VWS URL. - """ + """Tests for using a custom base VWS URL.""" - def test_custom_base_url(self, high_quality_image: io.BytesIO) -> None: + @staticmethod + def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: """ - It is possible to use add a target to a database under a custom VWS + It is possible to use add a target to a database under a custom + VWS URL. """ - base_vws_url = 'http://example.com' + base_vws_url = "http://example.com" with MockVWS(base_vws_url=base_vws_url) as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, @@ -115,38 +255,53 @@ def test_custom_base_url(self, high_quality_image: io.BytesIO) -> None: ) vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) + @staticmethod + def test_custom_base_url_with_path_prefix() -> None: + """ + A base VWS URL with a path prefix is used as-is, without the + prefix being dropped. + """ + base_vws_url = "http://example.com/prefix" + with MockVWS(base_vws_url=base_vws_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + ) + + assert not vws_client.list_targets() + class TestListTargets: - """ - Tests for listing targets. - """ + """Tests for listing targets.""" + @staticmethod def test_list_targets( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - It is possible to get a list of target IDs. - """ + """It is possible to get a list of target IDs.""" id_1 = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) id_2 = vws_client.add_target( - name='a', + name="a", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -154,22 +309,19 @@ def test_list_targets( class TestDelete: - """ - Test for deleting a target. - """ + """Test for deleting a target.""" + @staticmethod def test_delete_target( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - It is possible to delete a target. - """ + """It is possible to delete a target.""" target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -181,53 +333,66 @@ def test_delete_target( class TestGetTargetSummaryReport: - """ - Tests for getting a summary report for a target. - """ + """Tests for getting a summary report for a target.""" + @staticmethod def test_get_target_summary_report( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: + """Details of a target are returned by + ``get_target_summary_report``. """ - Details of a target are returned by ``get_target_summary_report``. - """ - date = '2018-04-25' + date = "2018-04-25" target_name = uuid.uuid4().hex - with freeze_time(date): + with freeze_time(time_to_freeze=date): target_id = vws_client.add_target( name=target_name, width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) - result = vws_client.get_target_summary_report(target_id=target_id) + report = vws_client.get_target_summary_report(target_id=target_id) expected_report = TargetSummaryReport( status=TargetStatuses.SUCCESS, - database_name=result.database_name, + database_name=report.database_name, target_name=target_name, - upload_date=datetime.date(2018, 4, 25), + upload_date=datetime.date(year=2018, month=4, day=25), active_flag=True, - tracking_rating=result.tracking_rating, + tracking_rating=report.tracking_rating, total_recos=0, current_month_recos=0, previous_month_recos=0, ) - assert result == expected_report + + assert report.status == expected_report.status + assert report.database_name == expected_report.database_name + assert report.target_name == expected_report.target_name + assert report.upload_date == expected_report.upload_date + assert report.active_flag == expected_report.active_flag + assert report.tracking_rating == expected_report.tracking_rating + assert report.total_recos == expected_report.total_recos + assert ( + report.current_month_recos == expected_report.current_month_recos + ) + assert ( + report.previous_month_recos == expected_report.previous_month_recos + ) + + assert report == expected_report class TestGetDatabaseSummaryReport: - """ - Tests for getting a summary report for a database. - """ + """Tests for getting a summary report for a database.""" - def test_get_target(self, vws_client: VWS) -> None: - """ - Details of a database are returned by ``get_database_summary_report``. + @staticmethod + def test_get_target(vws_client: VWS) -> None: + """Details of a database are returned by + ``get_database_summary_report``. """ report = vws_client.get_database_summary_report() @@ -245,26 +410,41 @@ def test_get_target(self, vws_client: VWS) -> None: target_quota=1000, total_recos=0, ) + + assert report.active_images == expected_report.active_images + assert ( + report.current_month_recos == expected_report.current_month_recos + ) + assert report.failed_images == expected_report.failed_images + assert report.inactive_images == expected_report.inactive_images + assert report.name == expected_report.name + assert ( + report.previous_month_recos == expected_report.previous_month_recos + ) + assert report.processing_images == expected_report.processing_images + assert report.reco_threshold == expected_report.reco_threshold + assert report.request_quota == expected_report.request_quota + assert report.request_usage == expected_report.request_usage + assert report.target_quota == expected_report.target_quota + assert report.total_recos == expected_report.total_recos + assert report == expected_report class TestGetTargetRecord: - """ - Tests for getting a record of a target. - """ + """Tests for getting a record of a target.""" + @staticmethod def test_get_target_record( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - Details of a target are returned by ``get_target_record``. - """ + """Details of a target are returned by ``get_target_record``.""" target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -273,33 +453,69 @@ def test_get_target_record( expected_target_record = TargetRecord( target_id=target_id, active_flag=True, - name='x', + name="x", width=1, tracking_rating=-1, - reco_rating='', + reco_rating="", ) assert result.target_record == expected_target_record + + assert ( + result.target_record.target_id == expected_target_record.target_id + ) + assert ( + result.target_record.active_flag + == expected_target_record.active_flag + ) + assert result.target_record.name == expected_target_record.name + assert result.target_record.width == expected_target_record.width + assert ( + result.target_record.tracking_rating + == expected_target_record.tracking_rating + ) + assert ( + result.target_record.reco_rating + == expected_target_record.reco_rating + ) + assert result.status == TargetStatuses.PROCESSING + @staticmethod + def test_get_failed( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, + ) -> None: + """Check that the report works with a failed target.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + result = vws_client.get_target_record(target_id=target_id) + + assert result.status == TargetStatuses.FAILED + class TestWaitForTargetProcessed: - """ - Tests for waiting for a target to be processed. - """ + """Tests for waiting for a target to be processed.""" + @staticmethod def test_wait_for_target_processed( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - It is possible to wait until a target is processed. - """ + """It is possible to wait until a target is processed.""" target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -309,25 +525,23 @@ def test_wait_for_target_processed( report = vws_client.get_target_summary_report(target_id=target_id) assert report.status != TargetStatuses.PROCESSING + @staticmethod def test_default_seconds_between_requests( - self, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - By default, 0.2 seconds are waited between polling requests. - """ + """By default, 0.2 seconds are waited between polling requests.""" with MockVWS(processing_time_seconds=0.5) as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, ) target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -361,25 +575,25 @@ def test_default_seconds_between_requests( expected_requests = 0 assert report.request_usage == expected_requests + @staticmethod def test_custom_seconds_between_requests( - self, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - It is possible to customize the time waited between polling requests. + """It is possible to customize the time waited between polling + requests. """ with MockVWS(processing_time_seconds=0.5) as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, ) target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -413,32 +627,30 @@ def test_custom_seconds_between_requests( expected_requests = 0 assert report.request_usage == expected_requests - def test_custom_timeout( - self, - high_quality_image: io.BytesIO, - ) -> None: - """ - It is possible to set a maximum timeout. - """ + @staticmethod + def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None: + """It is possible to set a maximum timeout.""" with MockVWS(processing_time_seconds=0.5) as mock: - database = VuforiaDatabase() - mock.add_database(database=database) + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, ) target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) report = vws_client.get_target_summary_report(target_id=target_id) assert report.status == TargetStatuses.PROCESSING - with pytest.raises(TargetProcessingTimeout): + with pytest.raises( + expected_exception=TargetProcessingTimeoutError + ): vws_client.wait_for_target_processed( target_id=target_id, timeout_seconds=0.1, @@ -453,29 +665,26 @@ def test_custom_timeout( class TestGetDuplicateTargets: - """ - Tests for getting duplicate targets. - """ + """Tests for getting duplicate targets.""" + @staticmethod def test_get_duplicate_targets( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - It is possible to get the IDs of similar targets. - """ + """It is possible to get the IDs of similar targets.""" target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) similar_target_id = vws_client.add_target( - name='a', + name="a", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) @@ -487,31 +696,28 @@ def test_get_duplicate_targets( class TestUpdateTarget: - """ - Tests for updating a target. - """ + """Tests for updating a target.""" + @staticmethod def test_update_target( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, different_high_quality_image: io.BytesIO, cloud_reco_client: CloudRecoService, ) -> None: - """ - It is possible to update a target. - """ + """It is possible to update a target.""" old_name = uuid.uuid4().hex - old_width = random.uniform(a=0.01, b=50) + old_width = secrets.choice(seq=range(1, 5000)) / 100 target_id = vws_client.add_target( name=old_name, width=old_width, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) - [matching_target] = cloud_reco_client.query(image=high_quality_image) + [matching_target] = cloud_reco_client.query(image=image) assert matching_target.target_id == target_id query_target_data = matching_target.target_data assert query_target_data is not None @@ -519,8 +725,10 @@ def test_update_target( assert query_metadata is None new_name = uuid.uuid4().hex - new_width = random.uniform(a=0.01, b=50) - new_application_metadata = base64.b64encode(b'a').decode('ascii') + new_width = secrets.choice(seq=range(1, 5000)) / 100 + new_application_metadata = base64.b64encode(s=b"a").decode( + encoding="ascii", + ) vws_client.update_target( target_id=target_id, name=new_name, @@ -550,20 +758,288 @@ def test_update_target( assert target_details.target_record.width == new_width assert not target_details.target_record.active_flag + @staticmethod def test_no_fields_given( - self, + *, vws_client: VWS, - high_quality_image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: - """ - It is possible to give no update fields. - """ + """It is possible to give no update fields.""" target_id = vws_client.add_target( - name='x', + name="x", width=1, - image=high_quality_image, + image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) vws_client.update_target(target_id=target_id) + + +class _ForbiddenDownloadTransport: + """A transport which refuses to serve a report, as an expired URL + would. + """ + + def close(self) -> None: + """Close the transport.""" + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a "forbidden" response.""" + del method, headers, request_timeout + body = "AccessDenied" + return Response( + text=body, + url=url, + status_code=HTTPStatus.FORBIDDEN, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(encoding="utf-8"), + ) + + +class TestRecoCountsReport: + """Tests for database reco counts reports.""" + + @staticmethod + def test_reco_counts_report( + *, + vws_client: VWS, + report_month: datetime.date, + ) -> None: + """A report can be requested, waited for and downloaded.""" + report_request = vws_client.request_database_reco_counts_report( + year=report_month.year, + month=calendar.Month(value=report_month.month), + ) + assert report_request.transaction_id + assert report_request.presigned_url + + report = vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + # No targets have been recognized, so the report has no rows. + assert not report.reco_counts + assert report.raw_csv.startswith(b"target_id,reco_count") + + @staticmethod + def test_not_ready(*, current_month: datetime.date) -> None: + """Downloading a report before Vuforia has generated it raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) + report_request = vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + with pytest.raises( + expected_exception=RecoCountsReportNotReadyError, + ) as exc: + vws_client.download_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + + @staticmethod + def test_wait_timeout(*, current_month: datetime.date) -> None: + """Waiting for a report which is not generated in time raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) + report_request = vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + maximum_wait_seconds = 5 + start_time = time.monotonic() + + with pytest.raises( + expected_exception=RecoCountsReportTimeoutError, + ): + vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + elapsed_time = time.monotonic() - start_time + assert elapsed_time < maximum_wait_seconds + + @staticmethod + @pytest.mark.parametrize( + argnames=("year", "month"), + argvalues=[ + pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"), + pytest.param( + 1999, + calendar.Month.DECEMBER, + id="year-in-the-past-december", + ), + ], + ) + def test_month_not_accepted( + *, + vws_client: VWS, + year: int, + month: calendar.Month, + ) -> None: + """Months other than the current and previous month are + rejected. + """ + with pytest.raises(expected_exception=FailError) as exc: + vws_client.request_database_reco_counts_report( + year=year, + month=month, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + + @staticmethod + def test_database_id_does_not_match_keys( + *, + current_month: datetime.date, + ) -> None: + """A database ID which does not match the given keys is + rejected. + """ + with MockVWS() as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=uuid.uuid4().hex, + ) + + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + + @staticmethod + def test_download_error() -> None: + """An error response from the report's URL raises an error.""" + vws_client = VWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + transport=_ForbiddenDownloadTransport(), + ) + + with pytest.raises( + expected_exception=RecoCountsReportDownloadError, + ) as exc: + vws_client.download_reco_counts_report( + presigned_url="https://example.com/reports/recoCounts/x", + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + @staticmethod + def test_no_database_id(*, current_month: datetime.date) -> None: + """A client which was given no database ID cannot request a + report. + """ + vws_client = VWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + ) + + with pytest.raises(expected_exception=DatabaseIdNotSetError): + vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + +class TestRecoCountsReportParsing: + """Tests for parsing downloaded reco counts reports.""" + + @staticmethod + def test_rows() -> None: + """Each row of the CSV becomes a ``RecoCount``.""" + csv_bytes = b"target_id,reco_count\r\nabc,3\r\ndef,0\r\n" + + report = RecoCountsReport.from_csv(csv_bytes=csv_bytes) + + assert report.reco_counts == [ + RecoCount(target_id="abc", reco_count=3), + RecoCount(target_id="def", reco_count=0), + ] + assert report.raw_csv == csv_bytes + + @staticmethod + def test_unknown_columns_ignored() -> None: + """Columns which are not known are not exposed in + ``reco_counts``. + """ + expected_reco_count = 3 + header = "target_id,reco_count,new_column\r\n" + csv_text = f"{header}abc,{expected_reco_count},x\r\n" + + report = RecoCountsReport.from_csv( + csv_bytes=csv_text.encode(encoding="utf-8"), + ) + + (item,) = report.reco_counts + assert item.target_id == "abc" + assert item.reco_count == expected_reco_count + + +class TestGenerateVumarkInstance: + """Tests for generating VuMark instances.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=("accept", "expected_prefix"), + argvalues=[ + pytest.param(VuMarkAccept.PNG, b"\x89PNG\r\n\x1a\n", id="png"), + pytest.param(VuMarkAccept.SVG, b"<", id="svg"), + pytest.param(VuMarkAccept.PDF, b"%PDF", id="pdf"), + ], + ) + def test_generate_vumark_instance( + *, + vumark_service_client: VuMarkService, + vumark_target_id: str, + accept: VuMarkAccept, + expected_prefix: bytes, + ) -> None: + """The returned bytes match the requested format.""" + result = vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="12345", + accept=accept, + ) + assert result.startswith(expected_prefix) diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index b549d8e95..5afb6639e 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -1,50 +1,63 @@ -""" -Tests for VWS exceptions. -""" +"""Tests for VWS exceptions.""" import io +import uuid from http import HTTPStatus import pytest from freezegun import freeze_time -from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws import MockVWS, VuMarkGenerationFailure +from mock_vws.database import CloudDatabase from mock_vws.states import States -from vws import VWS -from vws.exceptions.base_exceptions import VWSException -from vws.exceptions.custom_exceptions import UnknownVWSErrorPossiblyBadName +from vws import VWS, VuMarkService +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.custom_exceptions import ( + ServerError, +) from vws.exceptions.vws_exceptions import ( - AuthenticationFailure, - BadImage, + AuthenticationFailureError, + AuthorizationFailedError, + BadImageError, + BadRequestError, DateRangeError, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectHasNoAPIAccess, - ProjectInactive, - ProjectSuspended, - RequestQuotaReached, - RequestTimeTooSkewed, - TargetNameExist, - TargetQuotaReached, - TargetStatusNotSuccess, - TargetStatusProcessing, - UnknownTarget, + FailError, + ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + LicenseCheckFailedError, + MetadataTooLargeError, + ProjectHasNoAPIAccessError, + ProjectInactiveError, + ProjectSuspendedError, + QuotaExceededError, + RequestQuotaReachedError, + RequestTimeTooSkewedError, + TargetNameExistError, + TargetQuotaReachedError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + UnknownTargetError, ) +from vws.response import Response +from vws.vumark_accept import VuMarkAccept def test_image_too_large( + *, vws_client: VWS, - png_too_large: io.BytesIO, + png_too_large: io.BytesIO | io.BufferedRandom, ) -> None: """ - When giving an image which is too large, an ``ImageTooLarge`` exception is + When giving an image which is too large, an ``ImageTooLarge`` + exception + is raised. """ - with pytest.raises(ImageTooLarge) as exc: + with pytest.raises(expected_exception=ImageTooLargeError) as exc: vws_client.add_target( - name='x', + name="x", width=1, image=png_too_large, active_flag=True, @@ -56,24 +69,33 @@ def test_image_too_large( def test_invalid_given_id(vws_client: VWS) -> None: """ - Giving an invalid ID to a helper which requires a target ID to be given + Giving an invalid ID to a helper which requires a target ID to be + given causes an ``UnknownTarget`` exception to be raised. """ - target_id = '12345abc' - with pytest.raises(UnknownTarget) as exc: + target_id = "12345abc" + with pytest.raises(expected_exception=UnknownTargetError) as exc: vws_client.delete_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.NOT_FOUND assert exc.value.target_id == target_id -def test_add_bad_name(vws_client: VWS, high_quality_image: io.BytesIO) -> None: +def test_add_bad_name( + *, + vws_client: VWS, + high_quality_image: io.BytesIO, +) -> None: """ - When a name with a bad character is given, an - ``UnknownVWSErrorPossiblyBadName`` exception is raised. + When a name with a bad character is given, a ``ServerError`` + exception + is + raised. """ max_char_value = 65535 bad_name = chr(max_char_value + 1) - with pytest.raises(UnknownVWSErrorPossiblyBadName): + with pytest.raises( + expected_exception=ServerError, + ) as exc: vws_client.add_target( name=bad_name, width=1, @@ -82,27 +104,87 @@ def test_add_bad_name(vws_client: VWS, high_quality_image: io.BytesIO) -> None: application_metadata=None, ) + assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + def test_request_quota_reached() -> None: - """ - See https://github.com/VWS-Python/vws-python/issues/822 for writing - this test. - """ + """A ``RequestQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(request_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=RequestQuotaReachedError) as exc: + vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +def test_target_quota_reached(high_quality_image: io.BytesIO) -> None: + """A ``TargetQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(target_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=TargetQuotaReachedError) as exc: + vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.parametrize( + argnames=("state", "expected_exception"), + argvalues=[ + (States.PROJECT_SUSPENDED, ProjectSuspendedError), + (States.PROJECT_HAS_NO_API_ACCESS, ProjectHasNoAPIAccessError), + ], +) +def test_project_state_error( + *, + state: States, + expected_exception: type[VWSError], +) -> None: + """Configured project states raise their matching exceptions.""" + database = CloudDatabase(state=state) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=expected_exception) as exc: + vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN def test_fail(high_quality_image: io.BytesIO) -> None: - """ - A ``Fail`` exception is raised when the server access key does not exist. + """A ``Fail`` exception is raised when the server access key does not + exist. """ with MockVWS(): vws_client = VWS( - server_access_key='a', - server_secret_key='a', + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, ) - with pytest.raises(Fail) as exc: + with pytest.raises(expected_exception=FailError) as exc: vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, @@ -113,13 +195,11 @@ def test_fail(high_quality_image: io.BytesIO) -> None: def test_bad_image(vws_client: VWS) -> None: - """ - A ``BadImage`` exception is raised when a non-image is given. - """ - not_an_image = io.BytesIO(b'Not an image') - with pytest.raises(BadImage) as exc: + """A ``BadImage`` exception is raised when a non-image is given.""" + not_an_image = io.BytesIO(initial_bytes=b"Not an image") + with pytest.raises(expected_exception=BadImageError) as exc: vws_client.add_target( - name='x', + name="x", width=1, image=not_an_image, active_flag=True, @@ -130,23 +210,26 @@ def test_bad_image(vws_client: VWS) -> None: def test_target_name_exist( + *, vws_client: VWS, high_quality_image: io.BytesIO, ) -> None: """ - A ``TargetNameExist`` exception is raised after adding two targets with - the same name. + A ``TargetNameExist`` exception is raised after adding two targets + with + the + same name. """ vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, application_metadata=None, ) - with pytest.raises(TargetNameExist) as exc: + with pytest.raises(expected_exception=TargetNameExistError) as exc: vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, @@ -154,25 +237,28 @@ def test_target_name_exist( ) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - assert exc.value.target_name == 'x' + assert exc.value.target_name == "x" -def test_project_inactive(high_quality_image: io.BytesIO) -> None: +def test_project_inactive( + high_quality_image: io.BytesIO, +) -> None: """ A ``ProjectInactive`` exception is raised if adding a target to an - inactive database. + inactive + database. """ - database = VuforiaDatabase(state=States.PROJECT_INACTIVE) + database = CloudDatabase(state=States.PROJECT_INACTIVE) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, ) - with pytest.raises(ProjectInactive) as exc: + with pytest.raises(expected_exception=ProjectInactiveError) as exc: vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, @@ -183,22 +269,24 @@ def test_project_inactive(high_quality_image: io.BytesIO) -> None: def test_target_status_processing( + *, vws_client: VWS, high_quality_image: io.BytesIO, ) -> None: """ - A ``TargetStatusProcessing`` exception is raised if trying to delete a + A ``TargetStatusProcessing`` exception is raised if trying to delete + a target which is processing. """ target_id = vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, application_metadata=None, ) - with pytest.raises(TargetStatusProcessing) as exc: + with pytest.raises(expected_exception=TargetStatusProcessingError) as exc: vws_client.delete_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN @@ -206,35 +294,39 @@ def test_target_status_processing( def test_metadata_too_large( + *, vws_client: VWS, high_quality_image: io.BytesIO, ) -> None: """ - A ``MetadataTooLarge`` exception is raised if the metadata given is too + A ``MetadataTooLarge`` exception is raised if the metadata given is + too large. """ - with pytest.raises(MetadataTooLarge) as exc: + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, - application_metadata='a' * 1024 * 1024 * 10, + application_metadata="a" * 1024 * 1024 * 10, ) assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY def test_request_time_too_skewed( + *, vws_client: VWS, high_quality_image: io.BytesIO, ) -> None: """ - A ``RequestTimeTooSkewed`` exception is raised when the request time is + A ``RequestTimeTooSkewed`` exception is raised when the request time + is more than five minutes different from the server time. """ target_id = vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, @@ -251,32 +343,40 @@ def test_request_time_too_skewed( # * At least one time check when processing the request # # >= 1 ticks are acceptable. - with freeze_time(auto_tick_seconds=time_difference_from_now): - with pytest.raises(RequestTimeTooSkewed) as exc: - vws_client.get_target_record(target_id=target_id) + with ( + freeze_time(auto_tick_seconds=time_difference_from_now), + pytest.raises(expected_exception=RequestTimeTooSkewedError) as exc, + ): + vws_client.get_target_record(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN -def test_authentication_failure(high_quality_image: io.BytesIO) -> None: +def test_authentication_failure( + high_quality_image: io.BytesIO, +) -> None: """ - An ``AuthenticationFailure`` exception is raised when the server access key + An ``AuthenticationFailure`` exception is raised when the server + access + key exists but the server secret key is incorrect, or when a client key is incorrect. """ - database = VuforiaDatabase() + database = CloudDatabase() vws_client = VWS( server_access_key=database.server_access_key, - server_secret_key='a', + server_secret_key=uuid.uuid4().hex, ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) - with pytest.raises(AuthenticationFailure) as exc: + with pytest.raises( + expected_exception=AuthenticationFailureError + ) as exc: vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, @@ -287,22 +387,24 @@ def test_authentication_failure(high_quality_image: io.BytesIO) -> None: def test_target_status_not_success( + *, vws_client: VWS, high_quality_image: io.BytesIO, ) -> None: """ - A ``TargetStatusNotSuccess`` exception is raised when updating a target + A ``TargetStatusNotSuccess`` exception is raised when updating a + target which has a status which is not "Success". """ target_id = vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, application_metadata=None, ) - with pytest.raises(TargetStatusNotSuccess) as exc: + with pytest.raises(expected_exception=TargetStatusNotSuccessError) as exc: vws_client.update_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN @@ -310,47 +412,266 @@ def test_target_status_not_success( def test_vwsexception_inheritance() -> None: - """ - VWS-related exceptions should inherit from VWSException. - """ + """VWS-related exceptions should inherit from VWSException.""" subclasses = [ - AuthenticationFailure, - BadImage, + AuthenticationFailureError, + AuthorizationFailedError, + BadImageError, + BadRequestError, DateRangeError, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectInactive, - ProjectHasNoAPIAccess, - ProjectSuspended, - RequestQuotaReached, - RequestTimeTooSkewed, - TargetNameExist, - TargetQuotaReached, - TargetStatusNotSuccess, - TargetStatusProcessing, - UnknownTarget, + FailError, + ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + LicenseCheckFailedError, + MetadataTooLargeError, + ProjectInactiveError, + ProjectHasNoAPIAccessError, + ProjectSuspendedError, + QuotaExceededError, + RequestQuotaReachedError, + RequestTimeTooSkewedError, + TargetNameExistError, + TargetQuotaReachedError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + UnknownTargetError, ] for subclass in subclasses: - assert issubclass(subclass, VWSException) + assert issubclass(subclass, VWSError) -def test_base_exception( - vws_client: VWS, +def test_invalid_instance_id( + *, + vumark_service_client: VuMarkService, + vumark_target_id: str, +) -> None: + """ + An ``InvalidInstanceId`` exception is raised when an empty instance + ID is given. + """ + with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="", + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +def test_invalid_target_type( high_quality_image: io.BytesIO, ) -> None: """ - ``VWSException``s has a response property. + An ``InvalidTargetType`` exception is raised when trying to generate + a VuMark instance from a non-VuMark database. """ - with pytest.raises(VWSException) as exc: - vws_client.get_target_record(target_id='a') + database = CloudDatabase() + with MockVWS(processing_time_seconds=0.2) as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + target_id = vws_client.add_target( + name="example_target", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vumark_service = VuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + with pytest.raises( + expected_exception=InvalidTargetTypeError, + ) as exc: + vumark_service.generate_vumark_instance( + target_id=target_id, + instance_id="example_instance_id", + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.parametrize( + argnames=("failure", "exception_type", "status_code"), + argvalues=[ + ( + VuMarkGenerationFailure.QUOTA_EXCEEDED, + QuotaExceededError, + HTTPStatus.FORBIDDEN, + ), + ( + VuMarkGenerationFailure.LICENSE_CHECK_FAILED, + LicenseCheckFailedError, + HTTPStatus.FORBIDDEN, + ), + ( + VuMarkGenerationFailure.AUTHORIZATION_FAILED, + AuthorizationFailedError, + HTTPStatus.UNAUTHORIZED, + ), + ], +) +def test_documented_vumark_error_codes( + *, + failure: VuMarkGenerationFailure, + exception_type: type[VWSError], + status_code: HTTPStatus, +) -> None: + """Documented VuMark failures raise matching exceptions.""" + with MockVWS(vumark_generation_failure=failure): + vumark_service = VuMarkService( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + ) + + with pytest.raises(expected_exception=exception_type) as exc: + vumark_service.generate_vumark_instance( + target_id="exampletargetid", + instance_id="example_instance_id", + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == status_code + assert failure.value in exc.value.response.text + + +def test_base_exception( + *, + vws_client: VWS, + high_quality_image: io.BytesIO, +) -> None: + """``VWSException``s has a response property.""" + with pytest.raises(expected_exception=VWSError) as exc: + vws_client.get_target_record(target_id="a") assert exc.value.response.status_code == HTTPStatus.NOT_FOUND vws_client.add_target( - name='x', + name="x", width=1, image=high_quality_image, active_flag=True, application_metadata=None, ) + + +def test_vwserror_from_result_code() -> None: + """``VWSError.from_result_code`` returns the mapped exception.""" + response = Response( + text='{"result_code":"UnknownTarget"}', + url="https://example.com/targets/123", + status_code=HTTPStatus.NOT_FOUND, + headers={}, + request_body=None, + tell_position=0, + content=b"", + ) + + exception = VWSError.from_result_code( + result_code="UnknownTarget", + response=response, + ) + + assert isinstance(exception, UnknownTargetError) + assert exception.response is response + + +def test_project_has_no_api_access_casing() -> None: + """The ``ProjectHasNoApiAccess`` result code, as spelled in Vuforia's + result codes table, maps to ``ProjectHasNoAPIAccessError``. + """ + result_code = "ProjectHasNoApiAccess" + response = Response( + text=f'{{"result_code":"{result_code}"}}', + url="https://example.com/targets", + status_code=HTTPStatus.FORBIDDEN, + headers={}, + request_body=None, + tell_position=0, + content=b"", + ) + + exception = VWSError.from_result_code( + result_code=result_code, + response=response, + ) + + assert isinstance(exception, ProjectHasNoAPIAccessError) + + +@pytest.mark.parametrize( + argnames=("exception_type", "url"), + argvalues=[ + (UnknownTargetError, "https://vws.vuforia.com/targets/abc"), + (UnknownTargetError, "https://example.com/prefix/targets/abc"), + (UnknownTargetError, "https://example.com/prefix/summary/abc"), + (UnknownTargetError, "https://example.com/prefix/duplicates/abc"), + ( + TargetStatusProcessingError, + "https://vws.vuforia.com/targets/abc", + ), + ( + TargetStatusProcessingError, + "https://example.com/prefix/targets/abc", + ), + ( + TargetStatusNotSuccessError, + "https://vws.vuforia.com/targets/abc", + ), + ( + TargetStatusNotSuccessError, + "https://example.com/prefix/targets/abc", + ), + ( + TargetStatusNotSuccessError, + "https://example.com/prefix/targets/abc/instances", + ), + ], +) +def test_target_id_with_base_url_prefixes( + *, + exception_type: type[ + UnknownTargetError + | TargetStatusProcessingError + | TargetStatusNotSuccessError + ], + url: str, +) -> None: + """``target_id`` is correct even when ``base_vws_url`` has a path + prefix. + """ + response = Response( + text="{}", + url=url, + status_code=HTTPStatus.NOT_FOUND, + headers={}, + request_body=None, + tell_position=0, + content=b"", + ) + assert exception_type(response=response).target_id == "abc" + + +def test_target_id_missing_from_url() -> None: + """A clear error is raised when the response URL has no target ID.""" + response = Response( + text="{}", + url="https://example.com/no-target-here", + status_code=HTTPStatus.NOT_FOUND, + headers={}, + request_body=None, + tell_position=0, + content=b"", + ) + with pytest.raises( + expected_exception=ValueError, + match="Could not find a target ID", + ): + _ = UnknownTargetError(response=response).target_id diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 000000000..ce05fe5e8 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,10 @@ +--- +rules: + unpinned-uses: + disable: true + cache-poisoning: + disable: true + dependabot-cooldown: + disable: true + superfluous-actions: + disable: true