diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 610da79f8..13b3964d1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,3 +12,8 @@ updates: 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 8c8d881e8..21f12ba8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ --- - name: Test on: @@ -12,21 +11,24 @@ on: # Run at 1:00 every day - cron: 0 1 * * * +permissions: {} + jobs: build: - strategy: matrix: - python-version: ['3.13'] + python-version: ['3.14'] platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: '**/pyproject.toml' @@ -35,10 +37,16 @@ jobs: run: | # 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/ . --cov-report=xml + uv run --extra=dev --python=${{ matrix.python-version }} pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests/ . - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} + 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 index 5238c9f68..69fa53039 100644 --- a/.github/workflows/dependabot-merge.yml +++ b/.github/workflows/dependabot-merge.yml @@ -10,13 +10,8 @@ permissions: jobs: dependabot: runs-on: ubuntu-latest - if: github.actor == 'dependabot[bot]' + if: github.event.pull_request.user.login == 'dependabot[bot]' steps: - - name: Dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --merge "$PR_URL" env: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index def06bdb9..a1c124821 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,5 +1,4 @@ --- - name: Lint on: @@ -12,32 +11,47 @@ on: # Run at 1:00 every day - cron: 0 1 * * * +permissions: {} + jobs: build: - strategy: matrix: - python-version: ['3.13'] + python-version: ['3.14'] platform: [ubuntu-latest, windows-latest] + hook-stage: [pre-commit, pre-push, manual] runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: '**/pyproject.toml' - name: Lint - run: | - uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose - uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose - uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose + 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 }} - - uses: pre-commit-ci/lite-action@v1.1.0 - if: always() + completion-lint: + needs: build + runs-on: ubuntu-latest + if: always() # Run even if one matrix job fails + steps: + - name: Check matrix job status + run: |- + if ! ${{ needs.build.result == 'success' }}; then + echo "One or more matrix jobs failed" + exit 1 + fi diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml index 8f5a347a4..fceb50805 100644 --- a/.github/workflows/publish-site.yml +++ b/.github/workflows/publish-site.yml @@ -22,7 +22,7 @@ jobs: with: documentation_path: docs/source pyproject_extras: dev - python_version: '3.13' + 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 index e44c847b8..3337a2415 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,4 @@ --- - name: Release on: workflow_dispatch @@ -21,8 +20,8 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 - with: + - 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 }} @@ -34,7 +33,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: '**/pyproject.toml' @@ -48,26 +47,27 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Get the changelog underline - id: changelog_underline - run: | - underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')" - echo "underline=${underline}" >> "$GITHUB_OUTPUT" + # 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 - - name: Update changelog - uses: jacobtomlinson/gha-find-replace@v3 - with: - find: "Next\n----" - replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ - \ }}" - include: CHANGELOG.rst - regex: false + # 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@v5 + - uses: stefanzweifel/git-auto-commit-action@v7 id: commit with: commit_message: Bump CHANGELOG - file_pattern: CHANGELOG.rst + file_pattern: CHANGELOG.rst newsfragments # Error if there are no changes. skip_dirty_check: true @@ -86,12 +86,14 @@ jobs: tag: ${{ steps.tag_version.outputs.new_tag }} makeLatest: true name: Release ${{ steps.tag_version.outputs.new_tag }} - body: ${{ steps.tag_version.outputs.changelog }} + 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 ${{ steps.tag_version.outputs.new_tag }} + git checkout "$NEW_TAG" uv build --sdist --wheel --out-dir dist/ uv run --extra=release check-wheel-contents dist/*.whl diff --git a/.gitignore b/.gitignore index 556e31308..1b5882e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,6 @@ secrets.tar 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 index cf9bf80da..0bca59790 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,75 +1,60 @@ --- 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 -ci: - # We use system Python, with required dependencies specified in pyproject.toml. - # We therefore cannot use those dependencies in pre-commit CI. - skip: - - actionlint - - sphinx-lint - - check-manifest - - deptry - - doc8 - - docformatter - - docs - - interrogate - - interrogate-docs - - linkcheck - - mypy - - mypy-docs - - pylint - - pyproject-fmt-fix - - pyright - - pyright-docs - - pyright-verifytypes - - pyroma - - ruff-check-fix - - ruff-check-fix-docs - - ruff-format-fix - - ruff-format-fix-docs - - shellcheck - - shellcheck-docs - - shfmt - - shfmt-docs - - spelling - - vulture - - vulture-docs - - yamlfix - -default_install_hook_types: [pre-commit, pre-push, commit-msg] +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: v5.0.0 + 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 @@ -78,37 +63,46 @@ repos: language: python pass_filenames: false types_or: [yaml] - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - - id: docformatter - name: docformatter - entry: uv run --extra=dev -m docformatter --in-place + - id: pydocstringformatter + name: pydocstringformatter + entry: uv run --extra=dev pydocstringformatter language: python types_or: [python] - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: shellcheck name: shellcheck entry: uv run --extra=dev shellcheck --shell=bash language: python - pass_filenames: false types_or: [shell] - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: shellcheck-docs name: shellcheck-docs - entry: uv run --extra=dev doccmd --language=shell --language=console --command="shellcheck - --shell=bash" + 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==0.6.3] + 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==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: shfmt-docs name: shfmt-docs @@ -116,24 +110,30 @@ repos: --no-pad-file --command="shfmt --write --space-redirects --indent=4" language: python types_or: [markdown, rst] - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: mypy name: mypy stages: [pre-push] - entry: uv run --extra=dev -m mypy + entry: uv run --extra=dev -m mypy --num-workers=4 language: python types_or: [python, toml] pass_filenames: false - additional_dependencies: [uv==0.6.3] + 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 --language=python --command="mypy" + 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==0.6.3] + additional_dependencies: + - *uv_version - id: check-manifest name: check-manifest @@ -141,7 +141,8 @@ repos: entry: uv run --extra=dev -m check_manifest language: python pass_filenames: false - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version - id: pyright name: pyright @@ -150,15 +151,18 @@ repos: language: python types_or: [python, toml] pass_filenames: false - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version - id: pyright-docs name: pyright-docs stages: [pre-push] - entry: uv run --extra=dev doccmd --language=python --command="pyright" + 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==0.6.3] + additional_dependencies: + - *uv_version - id: vulture name: vulture @@ -166,14 +170,19 @@ repos: language: python types_or: [python] pass_filenames: false - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: vulture-docs name: vulture docs - entry: uv run --extra=dev doccmd --language=python --command="vulture" + 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==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: pyroma name: pyroma @@ -181,14 +190,18 @@ repos: language: python pass_filenames: false types_or: [toml] - additional_dependencies: [uv==0.6.3] + 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==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: pylint name: pylint @@ -196,36 +209,45 @@ repos: language: python stages: [manual] pass_filenames: false - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version - id: pylint-docs name: pylint-docs - entry: uv run --extra=dev doccmd --language=python --command="pylint" + 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==0.6.3] + 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==0.6.3] + 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==0.6.3] + 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==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: ruff-format-fix-docs name: Ruff format docs @@ -233,28 +255,87 @@ repos: format" language: python types_or: [markdown, rst] - additional_dependencies: [uv==0.6.3] + 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==0.6.3] + 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==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: interrogate-docs name: interrogate docs - entry: uv run --extra=dev doccmd --language=python --command="interrogate" + 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==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: pyproject-fmt-fix name: pyproject-fmt @@ -262,33 +343,40 @@ repos: language: python types_or: [toml] files: pyproject.toml - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version + stages: [pre-commit] - id: linkcheck name: linkcheck - entry: make -C docs/ linkcheck SPHINXOPTS=-W + 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==0.6.3] + additional_dependencies: + - *uv_version - id: spelling name: spelling - entry: make -C docs/ spelling SPHINXOPTS=-W + 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==0.6.3] + additional_dependencies: + - *uv_version - id: docs name: Build Documentation - entry: make docs + entry: uv run --extra=dev sphinx-build -M html docs/source docs/build -W language: python stages: [manual] pass_filenames: false - additional_dependencies: [uv==0.6.3] + additional_dependencies: + - *uv_version - id: pyright-verifytypes name: pyright-verifytypes @@ -297,18 +385,73 @@ repos: language: python pass_filenames: false types_or: [python] - additional_dependencies: [uv==0.6.3] + 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==0.6.3] + 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==0.6.3] + 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/CHANGELOG.rst b/CHANGELOG.rst index 5527f9da1..7c995c79a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,58 @@ Changelog ========= -Next ----- +.. towncrier release notes start + +2026.08.14 +---------- + +- 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 ------------ diff --git a/LICENSE b/LICENSE index ef26969f8..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,13 +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/Makefile b/Makefile deleted file mode 100644 index 216a25740..000000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -SHELL := /bin/bash -euxo pipefail - -# Treat Sphinx warnings as errors -SPHINXOPTS := -W - -.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 3cd5bb22c..b11993035 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|Build Status| |codecov| |PyPI| +|Build Status| |PyPI| vws-python ========== @@ -71,8 +71,6 @@ See the `full documentation `__. .. |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/main/graph/badge.svg - :target: https://codecov.io/gh/VWS-Python/vws-python .. |PyPI| image:: https://badge.fury.io/py/VWS-Python.svg :target: https://badge.fury.io/py/VWS-Python -.. |minimum-python-version| replace:: 3.13 +.. |minimum-python-version| replace:: 3.14 diff --git a/codecov.yaml b/codecov.yaml deleted file mode 100644 index 5c35baac9..000000000 --- a/codecov.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -coverage: - status: - patch: - default: - # Require 100% test coverage. - target: 100% diff --git a/conftest.py b/conftest.py index 1d776f8db..e4c927947 100644 --- a/conftest.py +++ b/conftest.py @@ -1,17 +1,14 @@ -""" -Setup for Sybil. -""" +"""Setup for Sybil.""" -import io +import io # noqa: TC003 import uuid -from collections.abc import Generator +from collections.abc import Generator # noqa: TC003 from doctest import ELLIPSIS from pathlib import Path import pytest -from beartype import beartype from mock_vws import MockVWS -from mock_vws.database import VuforiaDatabase +from mock_vws.database import CloudDatabase from sybil import Sybil from sybil.parsers.rest import ( ClearNamespaceParser, @@ -20,17 +17,9 @@ ) -def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """ - Apply the beartype decorator to all collected test functions. - """ - for item in items: - if isinstance(item, pytest.Function): - item.obj = beartype(obj=item.obj) - - @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. @@ -46,6 +35,7 @@ def fixture_make_image_file( @pytest.fixture(name="mock_vws") def fixture_mock_vws( + *, monkeypatch: pytest.MonkeyPatch, ) -> Generator[None]: """Yield a mock VWS. @@ -56,21 +46,34 @@ def fixture_mock_vws( server_secret_key = uuid.uuid4().hex client_access_key = uuid.uuid4().hex client_secret_key = uuid.uuid4().hex + database_id = uuid.uuid4().hex - database = VuforiaDatabase( + database = CloudDatabase( server_access_key=server_access_key, server_secret_key=server_secret_key, client_access_key=client_access_key, client_secret_key=client_secret_key, + database_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_database(database=database) + mock.add_cloud_database(cloud_database=database) yield diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 15e9c44b1..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: - @uv run --extra=dev $(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @uv run --extra=dev $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/__init__.py b/docs/source/__init__.py index b63eed5fb..535ceb2ec 100644 --- a/docs/source/__init__.py +++ b/docs/source/__init__.py @@ -1,3 +1 @@ -""" -Documentation. -""" +"""Documentation.""" diff --git a/docs/source/api-reference.rst b/docs/source/api-reference.rst index 01bba09b2..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: @@ -13,6 +37,14 @@ API Reference :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 8c7e78f05..12553403f 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Configuration for Sphinx. -""" +"""Configuration for Sphinx.""" import importlib.metadata from pathlib import Path @@ -25,8 +23,17 @@ "sphinx.ext.napoleon", "sphinx_substitution_extensions", "sphinxcontrib.spelling", + "sphinxcontrib.towncrier.ext", ] +# 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" @@ -60,6 +67,9 @@ html_show_sourcelink = False html_theme_options = { "sidebar_hide_name": False, + "source_repository": "https://github.com/VWS-Python/vws-python/", + "source_branch": "main", + "source_directory": "docs/source/", } # Output file base name for HTML help builder. diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 21e5dbe82..55d902399 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -27,11 +27,11 @@ and on Ubuntu with ``apt``: $ apt-get install -y enchant -Install ``pre-commit`` hooks: +Install ``prek`` hooks: .. code-block:: console - $ pre-commit install + $ prek install Linting ------- @@ -40,9 +40,9 @@ Run lint tools either by committing, or with: .. code-block:: console - $ pre-commit run --all-files --hook-stage pre-commit --verbose - $ pre-commit run --all-files --hook-stage pre-push --verbose - $ pre-commit run --all-files --hook-stage manual --verbose + $ prek run --all-files --hook-stage pre-commit --verbose + $ prek run --all-files --hook-stage pre-push --verbose + $ prek run --all-files --hook-stage manual --verbose .. _Homebrew: https://brew.sh @@ -64,8 +64,8 @@ Run the following commands to build and view documentation locally: .. 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 f48730bfd..baea7b495 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -28,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 818b0698d..c8b5bdaf6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -60,6 +60,132 @@ See the :doc:`api-reference` for full usage details. 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 ------- @@ -78,13 +204,13 @@ To write unit tests for code which uses this library, without using your Vuforia import pathlib from mock_vws import MockVWS - from mock_vws.database import VuforiaDatabase + from mock_vws.database import CloudDatabase from vws import VWS, CloudRecoService with MockVWS() 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, @@ -124,4 +250,5 @@ Reference exceptions contributing release-process + unreleased changelog 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/newsfragments/.gitkeep b/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pyproject.toml b/pyproject.toml index 6152cd7c7..da92a9be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,6 @@ build-backend = "setuptools.build_meta" requires = [ "setuptools", "setuptools-scm>=8.1.0", - "wheel", ] [project] @@ -15,94 +14,100 @@ keywords = [ "vuforia", "vws", ] -license = { file = "LICENSE" } +license = "MIT" authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, ] -requires-python = ">=3.13" +requires-python = ">=3.14" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", - "License :: OSI Approved :: MIT License", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = [ "version", ] dependencies = [ - "beartype>=0.18.5", + "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.7.23", - "check-manifest==0.50", - "deptry==0.23.0", - "doc8==1.1.2", - "doccmd==2025.3.6", - "docformatter==1.7.5", - "freezegun==1.5.1", - "furo==2024.8.6", + "actionlint-py==1.7.12.24", + "check-manifest==0.51", + "deptry==0.25.1", + "doc8==2.0.0", + "doccmd==2026.7.19", + "freezegun==1.5.5", + "furo==2025.12.19", "interrogate==1.7.0", - "mypy[faster-cache]==1.15.0", - "mypy-strict-kwargs==2024.12.25", - "pre-commit==4.1.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", - "pyenchant==3.3.0rc1", - "pygments==2.19.1", - "pylint==3.3.4", - "pylint-per-file-ignores==1.4.0", - "pyproject-fmt==2.5.1", - "pyright==1.1.396", - "pyroma==4.2", - "pytest==8.3.5", - "pytest-cov==6.0.0", - "pyyaml==6.0.2", - "ruff==0.10.0", + "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.4.26", + "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.10.0.1", - "shfmt-py==3.11.0.2", - "sphinx==8.2.3", + "shellcheck-py==0.11.0.1", + "shfmt-py==4.0.0", + "sphinx==9.1.0", "sphinx-copybutton==0.5.2", - "sphinx-lint==1.0.0", + "sphinx-lint==1.0.2", "sphinx-pyproject==0.3.0", - "sphinx-substitution-extensions==2025.3.3", - "sphinxcontrib-spelling==8.0.1", - "sybil==9.1.0", - "types-requests==2.32.0.20250306", - "vulture==2.14", - "vws-python-mock==2025.3.10.1", + "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==2023.3.5", - "yamlfix==1.17.0", + "yamlfix==1.19.1", + "zizmor==1.29.0", ] -optional-dependencies.release = [ "check-wheel-contents==0.6.1" ] +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.setuptools] -zip-safe = false +[dependency-groups] +dev = [] -[tool.setuptools.packages.find] -where = [ +[tool.setuptools] +packages.find.where = [ "src", ] - -[tool.setuptools.package-data] -vws = [ +package-data.vws = [ "py.typed", ] - -[tool.distutils.bdist_wheel] -universal = true +zip-safe = false [tool.setuptools_scm] - # This keeps the start of the version the same as the last release. # This is useful for our documentation to include e.g. binary links # to the latest released binary. @@ -112,104 +117,47 @@ version_scheme = "post-release" [tool.ruff] line-length = 79 - lint.select = [ "ALL", ] lint.ignore = [ # Ruff warns that this conflicts with the formatter. "COM812", - # Allow our chosen docstring line-style - no one-line summary. - "D200", + # 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 + # 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.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. -# 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. -load-plugins = [ - "pylint_per_file_ignores", - 'pylint.extensions.bad_builtin', - 'pylint.extensions.comparison_placement', - 'pylint.extensions.consider_refactoring_into_while_condition', - 'pylint.extensions.docparams', - 'pylint.extensions.dunder', - 'pylint.extensions.eq_without_hash', - 'pylint.extensions.for_any_all', - 'pylint.extensions.mccabe', - 'pylint.extensions.no_self_use', - 'pylint.extensions.overlapping_exceptions', - 'pylint.extensions.private_import', - 'pylint.extensions.redefined_loop_name', - 'pylint.extensions.redefined_variable_type', - 'pylint.extensions.set_membership', - 'pylint.extensions.typing', -] - -# 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 = [ - 'bad-inline-option', - 'deprecated-pragma', - 'file-ignored', - 'spelling', - 'use-symbolic-message-instead', - 'useless-suppression', -] - # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration @@ -219,164 +167,183 @@ enable = [ # --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 = [ - 'too-few-public-methods', - 'too-many-locals', - 'too-many-arguments', - 'too-many-instance-attributes', - 'too-many-return-statements', - 'too-many-lines', - 'locally-disabled', +"MESSAGES CONTROL".disable = [ + # Too difficult to please + "duplicate-code", # Let ruff handle long lines - 'line-too-long', - # Let ruff handle unused imports - 'unused-import', - # Let ruff deal with sorting - 'ungrouped-imports', + "line-too-long", + "locally-disabled", + "missing-return-type-doc", # We don't need everything to be documented because of mypy - 'missing-type-doc', - 'missing-return-type-doc', - # Too difficult to please - 'duplicate-code', - # Let ruff handle imports - 'wrong-import-order', + "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', + "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. -per-file-ignores = [ - "docs/:invalid-name", - "doccmd_README_rst.*.py:invalid-name", +"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", ] - -[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'] - +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-dict = 'en_US' - +SPELLING.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' - +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-store-unknown-words = 'no' +SPELLING.spelling-store-unknown-words = "no" -[tool.docformatter] -make-summary-multi-line = true +[tool.interrogate] +fail-under = 100 +verbose = 2 +omit-covered-files = true [tool.check-manifest] - ignore = [ - ".checkmake-config.ini", - ".yamlfmt", "*.enc", + ".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", + "LICENSE", + "lint.mk", + "Makefile", + "newsfragments", + "newsfragments/**", "spelling_private_dict.txt", "tests", "tests-pylintrc", "tests/**", "vuforia_secrets.env.example", - "lint.mk", + "zizmor.yml", ] [tool.deptry] -pep621_dev_dependency_groups = [ +optional_dependencies_dev_groups = [ "dev", "release", ] -[tool.pyproject-fmt] -indent = 4 -keep_full_version = true -max_supported_python = "3.13" - -[tool.pytest.ini_options] - -xfail_strict = true -log_cli = true - -[tool.coverage.run] - -branch = true - -[tool.coverage.report] -exclude_also = [ - "if TYPE_CHECKING:", -] - -[tool.mypy] - -strict = true -files = [ "." ] -exclude = [ "build" ] -follow_untyped_imports = true -plugins = [ - "mypy_strict_kwargs", -] - -[tool.pyright] - -enableTypeIgnoreComments = false -reportUnnecessaryTypeIgnoreComment = true -typeCheckingMode = "strict" - -[tool.interrogate] -fail-under = 100 -omit-covered-files = true -verbose = 2 - -[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.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 = [ - # pytest configuration - "pytest_collect_file", - "pytest_collection_modifyitems", - "pytest_plugins", - # pytest fixtures - we name fixtures like this for this purpose - "fixture_*", + # 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", @@ -384,25 +351,120 @@ ignore_names = [ "html_theme_options", "html_title", "htmlhelp_basename", + "HTTPXTransport", + "IGES", "intersphinx_mapping", "language", "linkcheck_ignore", "linkcheck_retries", + "LOW_FEATURE_OBJECTS", "master_doc", - "nitpicky", + "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", ] -# Duplicate some of .gitignore -exclude = [ ".venv" ] +[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/spelling_private_dict.txt b/spelling_private_dict.txt index ce782a4af..09386f1d4 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,18 +1,23 @@ AuthenticationFailure +AuthorizationFailed BadImage ConnectionErrorPossiblyImageTooLarge DateRangeError +Falsy ImageTooLarge InactiveProject JSONDecodeError +LicenseCheckFailed MatchProcessing MaxNumResultsOutOfRange MetadataTooLarge +OAuth OopsAnErrorOccurredPossiblyBadName OopsAnErrorOccurredPossiblyBadNameError -ProjectHasNoAPIAccess +ProjectHasNoApiAccess ProjectInactive ProjectSuspended +QuotaExceeded RequestQuotaReached RequestTimeTooSkewed TargetNameExist @@ -27,6 +32,9 @@ admin api args ascii +async +asyncio +balancer beartype bool boolean @@ -35,6 +43,7 @@ changelog chunked cmyk connectionerror +csv customizable dataclasses datetime @@ -42,6 +51,8 @@ decodable dev dict docstring +enum +falsy filename foo formdata @@ -53,6 +64,7 @@ hmac html http https +httpx iff io issuecomment @@ -61,6 +73,7 @@ json keyring kib kwargs +lifecycle linters linting login @@ -79,6 +92,7 @@ pyright pytest readme readthedocs +reco recognitions refactoring regex @@ -100,6 +114,7 @@ usefixtures validators vuforia vuforia's +vumark vwq vws xxx diff --git a/src/vws/__init__.py b/src/vws/__init__.py index 42788b92d..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__ = [ "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 943323bfd..616e0682f 100644 --- a/src/vws/exceptions/base_exceptions.py +++ b/src/vws/exceptions/base_exceptions.py @@ -1,18 +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 collections.abc import Mapping # noqa: TC003 +from typing import ClassVar + from beartype import beartype -from vws.response import Response +from vws.response import Response # noqa: TC001 @beartype class CloudRecoError(Exception): - """ - Base class for Vuforia Cloud Recognition Web API exceptions. - """ + """Base class for Vuforia Cloud Recognition Web API exceptions.""" def __init__(self, response: Response) -> None: """ @@ -24,9 +26,7 @@ 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 @@ -38,6 +38,8 @@ class VWSError(Exception): 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: @@ -46,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 ff5dde209..b2e3ee67f 100644 --- a/src/vws/exceptions/cloud_reco_exceptions.py +++ b/src/vws/exceptions/cloud_reco_exceptions.py @@ -1,5 +1,6 @@ -""" -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 beartype import beartype @@ -17,31 +18,27 @@ class MaxNumResultsOutOfRangeError(CloudRecoError): @beartype class InactiveProjectError(CloudRecoError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'InactiveProject'. """ @beartype class BadImageError(CloudRecoError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'BadImage'. """ @beartype class AuthenticationFailureError(CloudRecoError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. """ @beartype class RequestTimeTooSkewedError(CloudRecoError): - """ - Exception raised when Vuforia returns a response with a result code + """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 694e98c30..70ec81e36 100644 --- a/src/vws/exceptions/custom_exceptions.py +++ b/src/vws/exceptions/custom_exceptions.py @@ -1,19 +1,17 @@ -""" -Exceptions which do not map to errors at +"""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 -or simple errors given by the cloud recognition service. """ from beartype import beartype -from vws.response import Response +from vws.response import Response # noqa: TC001 @beartype class RequestEntityTooLargeError(Exception): - """ - Exception raised when the given image is too large. - """ + """Exception raised when the given image is too large.""" def __init__(self, response: Response) -> None: """ @@ -25,36 +23,85 @@ 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 @beartype class TargetProcessingTimeoutError(Exception): + """Exception raised when waiting for a target to be processed times + out. """ - Exception raised when waiting for a target to be processed times out. + + +@beartype +class DatabaseIdNotSetError(Exception): + """Exception raised when an operation which needs a database ID is used + on a client which was not given one. """ @beartype -class ServerError(Exception): # pragma: no cover +class RecoCountsReportNotReadyError(Exception): + """Exception raised when a reco counts report is downloaded before + Vuforia has generated it. """ - Exception raised when VWS returns a server error. + + 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 RecoCountsReportDownloadError(Exception): + """Exception raised when downloading a reco counts report fails. + + This is raised, for example, when the report's URL has expired. """ def __init__(self, response: Response) -> None: """ Args: - response: The response returned by Vuforia. + 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: """ - The response returned by Vuforia which included this error. + 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 482223766..216677f15 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -1,7 +1,9 @@ """ -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://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. +https://developer.vuforia.com/library/web-api/cloud-targets-web-services- +api#result-codes. """ import json @@ -12,146 +14,135 @@ from vws.exceptions.base_exceptions import VWSError +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``. + """ + 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 + """Exception raised when Vuforia returns a response with a result code 'UnknownTarget'. """ @property def target_id(self) -> str: - """ - The unknown target ID. - """ - path = urlparse(url=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 FailError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code 'Fail'. + """Exception raised when Vuforia returns a response with a result code + 'Fail'. """ @beartype class BadImageError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'BadImage'. """ @beartype class AuthenticationFailureError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. """ -# See https://github.com/VWS-Python/vws-python/issues/822. @beartype -class RequestQuotaReachedError(VWSError): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code +class RequestQuotaReachedError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'RequestQuotaReached'. """ @beartype class TargetStatusProcessingError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'TargetStatusProcessing'. """ @property def target_id(self) -> str: - """ - The processing target ID. - """ - path = urlparse(url=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. @beartype class DateRangeError(VWSError): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'DateRangeError'. """ -# This is not simulated by the mock. @beartype -class TargetQuotaReachedError(VWSError): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code +class TargetQuotaReachedError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'TargetQuotaReached'. """ -# This is not simulated by the mock. @beartype -class ProjectSuspendedError(VWSError): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code +class ProjectSuspendedError(VWSError): + """Exception raised when Vuforia returns a response with a result code 'ProjectSuspended'. """ -# This is not simulated by the mock. @beartype -class ProjectHasNoAPIAccessError(VWSError): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code - 'ProjectHasNoAPIAccess'. +class ProjectHasNoAPIAccessError(VWSError): + """Exception raised when Vuforia returns a response with a result code + 'ProjectHasNoApiAccess'. """ @beartype class ProjectInactiveError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'ProjectInactive'. """ @beartype class MetadataTooLargeError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'MetadataTooLarge'. """ @beartype class RequestTimeTooSkewedError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'RequestTimeTooSkewed'. """ @beartype class TargetNameExistError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'TargetNameExist'. """ @property def target_name(self) -> str: - """ - The target name which already exists. - """ + """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"]) @@ -159,33 +150,107 @@ def target_name(self) -> str: @beartype class ImageTooLargeError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'ImageTooLarge'. """ @beartype class TargetStatusNotSuccessError(VWSError): - """ - Exception raised when Vuforia returns a response with a result code + """Exception raised when Vuforia returns a response with a result code 'TargetStatusNotSuccess'. """ @property def target_id(self) -> str: - """ - The unknown target ID. - """ - path = urlparse(url=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 + """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 37682e08a..0692a1e40 100644 --- a/src/vws/include_target_data.py +++ b/src/vws/include_target_data.py @@ -1,6 +1,4 @@ -""" -Tools for managing ``CloudRecoService.query``'s ``include_target_data``. -""" +"""Tools for managing ``CloudRecoService.query``'s ``include_target_data``.""" from enum import StrEnum, auto, unique 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 96122aa14..3f69261ef 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -1,19 +1,16 @@ -""" -Tools for interacting with the Vuforia Cloud Recognition Web APIs. -""" +"""Tools for interacting with the Vuforia Cloud Recognition Web APIs.""" -import datetime -import io import json from http import HTTPMethod, HTTPStatus -from typing import Any, BinaryIO -from urllib.parse import urljoin +from typing import Any -import requests -from beartype import beartype +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, @@ -26,55 +23,55 @@ ServerError, ) from vws.include_target_data import CloudRecoIncludeTargetData -from vws.reports import QueryResult, TargetData -from vws.response import Response +from vws.reports import QueryResult +from vws.transports import RequestsTransport, Transport -_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 - - -@beartype +@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", + 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: _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. + """Use the Vuforia Web Query API to make an Image Recognition + Query. See https://developer.vuforia.com/library/web-api/vuforia-query-web-api @@ -105,6 +102,10 @@ def query( 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. @@ -141,21 +142,12 @@ def query( "Content-Type": content_type_header, } - requests_response = requests.request( + response = self._transport( method=method, - url=urljoin(base=self._base_vwq_url, url=request_path), + url=self._base_vwq_url.rstrip("/") + request_path, headers=headers, data=content, - # We should make the timeout customizable. - timeout=30, - ) - response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), + request_timeout=self._request_timeout_seconds, ) if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: @@ -169,7 +161,23 @@ def query( ): # pragma: no cover raise ServerError(response=response) - result_code = json.loads(s=response.text)["result_code"] + 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, @@ -179,28 +187,8 @@ def query( }[result_code] raise exception(response=response) - result: list[QueryResult] = [] - result_list = list(json.loads(s=response.text)["results"]) - for item in result_list: - target_data: TargetData | None = 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.fromtimestamp( - timestamp=timestamp_string, - tz=datetime.UTC, - ) - 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 f6a77133c..2be447b03 100644 --- a/src/vws/reports.py +++ b/src/vws/reports.py @@ -1,16 +1,18 @@ -""" -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, unique +from typing import Any, Self from beartype import BeartypeConf, beartype @beartype -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class DatabaseSummaryReport: """A database summary report. @@ -31,6 +33,24 @@ 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 @@ -47,7 +67,7 @@ class TargetStatuses(Enum): @beartype -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class TargetSummaryReport: """A target summary report. @@ -65,9 +85,26 @@ 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"]), + ) + @beartype(conf=BeartypeConf(is_pep484_tower=True)) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class TargetRecord: """A target record. @@ -84,11 +121,9 @@ class TargetRecord: @beartype -@dataclass(frozen=True) +@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: str | None @@ -96,7 +131,7 @@ class TargetData: @beartype -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class QueryResult: """One query match result. @@ -107,9 +142,29 @@ class QueryResult: target_id: str 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, + ) + @beartype -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class TargetStatusAndRecord: """The target status and a target record. @@ -119,3 +174,218 @@ class TargetStatusAndRecord: 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 index 269f2e23f..d6456819e 100644 --- a/src/vws/response.py +++ b/src/vws/response.py @@ -1,18 +1,14 @@ -""" -Responses for requests to VWS and VWQ. -""" +"""Responses for requests to VWS and VWQ.""" from dataclasses import dataclass from beartype import beartype -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) @beartype class Response: - """ - A response from a request. - """ + """A response from a request.""" text: str url: str @@ -20,3 +16,4 @@ class Response: 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 5dd7503db..2b241ab1d 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -1,155 +1,80 @@ -""" -Tools for interacting with Vuforia APIs. -""" +"""Tools for interacting with Vuforia APIs.""" import base64 -import io +import calendar # noqa: TC003 import json import time -from datetime import date from http import HTTPMethod, HTTPStatus -from typing import BinaryIO -from urllib.parse import urljoin -import requests from beartype import BeartypeConf, beartype -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._reco_counts import ( + reco_counts_report_body, + reco_counts_report_path, + report_from_download_response, +) +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 ( - AuthenticationFailureError, - BadImageError, - DateRangeError, - FailError, - ImageTooLargeError, - MetadataTooLargeError, - ProjectHasNoAPIAccessError, - ProjectInactiveError, - ProjectSuspendedError, - RequestQuotaReachedError, - RequestTimeTooSkewedError, - TargetNameExistError, - TargetQuotaReachedError, - TargetStatusNotSuccessError, - TargetStatusProcessingError, - TooManyRequestsError, - UnknownTargetError, -) +from vws.exceptions.vws_exceptions import TooManyRequestsError from vws.reports import ( DatabaseSummaryReport, - TargetRecord, + RecoCountsReport, + RecoCountsReportRequest, TargetStatusAndRecord, TargetStatuses, TargetSummaryReport, ) -from vws.response import Response - -_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 - - -@beartype -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, -) -> Response: - """Make a request to the Vuforia Target API. - - This uses `requests` to make a request against https://vws.vuforia.com. - - 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. - - Returns: - The response to the request made by `requests`. - """ - 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, - } - - url = urljoin(base=base_vws_url, url=request_path) - - requests_response = requests.request( - method=method, - url=url, - headers=headers, - data=data, - # We should make the timeout customizable. - timeout=30, - ) - - 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(), - ) +from vws.response import Response # noqa: TC001 +from vws.transports import RequestsTransport, Transport @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", + 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( self, @@ -159,33 +84,37 @@ def make_request( 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 Vuforia. - 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 + method: The HTTP method which will be used in + the request. + data: The request body which will be used in the request. - expected_result_code: See "VWS API Result Codes" on + 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 made by `requests`. + 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. + ~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, @@ -193,6 +122,9 @@ def make_request( 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 ( @@ -211,34 +143,18 @@ def make_request( if result_code == expected_result_code: return response - exception = { - "AuthenticationFailure": AuthenticationFailureError, - "BadImage": BadImageError, - "DateRangeError": DateRangeError, - "Fail": FailError, - "ImageTooLarge": ImageTooLargeError, - "MetadataTooLarge": MetadataTooLargeError, - "ProjectHasNoAPIAccess": ProjectHasNoAPIAccessError, - "ProjectInactive": ProjectInactiveError, - "ProjectSuspended": ProjectSuspendedError, - "RequestQuotaReached": RequestQuotaReachedError, - "RequestTimeTooSkewed": RequestTimeTooSkewedError, - "TargetNameExist": TargetNameExistError, - "TargetQuotaReached": TargetQuotaReachedError, - "TargetStatusNotSuccess": TargetStatusNotSuccessError, - "TargetStatusProcessing": TargetStatusProcessingError, - "UnknownTarget": UnknownTargetError, - }[result_code] - - raise exception(response=response) + raise VWSError.from_result_code( + result_code=result_code, + response=response, + ) 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. @@ -348,23 +264,13 @@ def get_target_record(self, target_id: str) -> TargetStatusAndRecord: ) result_data = json.loads(s=response.text) - status = TargetStatuses(value=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"], - ) - return TargetStatusAndRecord( - status=status, - target_record=target_record, + return TargetStatusAndRecord.from_response_dict( + response_dict=result_data, ) def wait_for_target_processed( self, + *, target_id: str, seconds_between_requests: float = 0.2, timeout_seconds: float = 60 * 5, @@ -404,6 +310,10 @@ def wait_for_target_processed( 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 elapsed_time = time.monotonic() - start_time @@ -480,16 +390,8 @@ def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: ) result_data = dict(json.loads(s=response.text)) - return TargetSummaryReport( - status=TargetStatuses(value=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"], + return TargetSummaryReport.from_response_dict( + response_dict=result_data, ) def get_database_summary_report(self) -> DatabaseSummaryReport: @@ -523,21 +425,140 @@ def get_database_summary_report(self) -> DatabaseSummaryReport: ) response_data = dict(json.loads(s=response.text)) - return 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"], + return DatabaseSummaryReport.from_response_dict( + response_dict=response_data, ) + 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. + """ + 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. @@ -573,7 +594,8 @@ def delete_target(self, target_id: str) -> None: ) def get_duplicate_targets(self, target_id: str) -> list[str]: - """Get targets which may be considered duplicates of a given target. + """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. diff --git a/tests/__init__.py b/tests/__init__.py index c7e38a862..3502d86d5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1 @@ -""" -Tests for ``vws``. -""" +"""Tests for ``vws``.""" diff --git a/tests/conftest.py b/tests/conftest.py index 74c6fc56d..74d4137be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,62 +1,227 @@ -""" -Configuration, plugins and fixtures for `pytest`. -""" +"""Configuration, plugins and fixtures for `pytest`.""" -import io -from collections.abc import Generator -from pathlib import Path +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(name="_mock_database") -def fixture_mock_database() -> Generator[VuforiaDatabase]: - """ - Yield a mock ``VuforiaDatabase``. - """ +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 = VuforiaDatabase() - mock.add_database(database=database) + 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 = VuMarkDatabase(vumark_targets={vumark_target}) + mock.add_vumark_database(vumark_database=database) yield database @pytest.fixture -def vws_client(_mock_database: VuforiaDatabase) -> VWS: - """ - A VWS client which connects to a mock database. - """ +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) -> CloudRecoService: - """ - A ``CloudRecoService`` client which connects to a mock database. - """ +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. + """ + 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. - """ + """An image file object.""" file = tmp_path / "image.jpg" buffer = high_quality_image.getvalue() file.write_bytes(data=buffer) @@ -67,13 +232,12 @@ def fixture_image_file( @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. - """ + """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 3515b0d22..29632000c 100644 --- a/tests/test_cloud_reco_exceptions.py +++ b/tests/test_cloud_reco_exceptions.py @@ -1,14 +1,13 @@ -""" -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 -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 CloudRecoService @@ -26,6 +25,7 @@ def test_too_many_max_results( + *, cloud_reco_client: CloudRecoService, high_quality_image: io.BytesIO, ) -> None: @@ -47,11 +47,13 @@ def test_too_many_max_results( def test_image_too_large( + *, cloud_reco_client: CloudRecoService, png_too_large: io.BytesIO | io.BufferedRandom, ) -> None: """ - A ``RequestEntityTooLarge`` exception is raised if an image which is too + A ``RequestEntityTooLarge`` exception is raised if an image which is + too large is given. """ with pytest.raises(expected_exception=RequestEntityTooLargeError) as exc: @@ -63,8 +65,8 @@ def test_image_too_large( def test_cloudrecoexception_inheritance() -> None: - """ - CloudRecoService-specific exceptions inherit from CloudRecoException. + """CloudRecoService-specific exceptions inherit from + CloudRecoException. """ subclasses = [ MaxNumResultsOutOfRangeError, @@ -81,16 +83,18 @@ 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=uuid.uuid4().hex, ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) with pytest.raises( expected_exception=AuthenticationFailureError @@ -107,9 +111,9 @@ def test_inactive_project( 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, @@ -123,3 +127,73 @@ def test_inactive_project( # 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_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 e229cf95d..dac206b25 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,43 +1,41 @@ -""" -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( + *, cloud_reco_client: CloudRecoService, image: io.BytesIO | BinaryIO, ) -> None: - """ - An empty list is returned if there are no matches. - """ + """An empty list is returned if there are no matches.""" result = cloud_reco_client.query(image=image) assert result == [] @staticmethod def test_match( + *, vws_client: VWS, cloud_reco_client: CloudRecoService, 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", width=1, @@ -50,21 +48,122 @@ def test_match( 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.""" @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" 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, @@ -91,21 +190,51 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: 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( + *, vws_client: VWS, cloud_reco_client: CloudRecoService, 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, @@ -127,13 +256,12 @@ def test_default( @staticmethod def test_custom( + *, vws_client: VWS, cloud_reco_client: CloudRecoService, 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, @@ -167,19 +295,16 @@ def test_custom( class TestIncludeTargetData: - """ - Tests for the ``include_target_data`` parameter of ``query``. - """ + """Tests for the ``include_target_data`` parameter of ``query``.""" @staticmethod def test_default( + *, vws_client: VWS, cloud_reco_client: CloudRecoService, 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, @@ -205,12 +330,14 @@ def test_default( @staticmethod def test_top( + *, vws_client: VWS, cloud_reco_client: CloudRecoService, 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( @@ -239,12 +366,15 @@ def test_top( @staticmethod def test_none( + *, vws_client: VWS, cloud_reco_client: CloudRecoService, 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( @@ -273,6 +403,7 @@ def test_none( @staticmethod def test_all( + *, vws_client: VWS, cloud_reco_client: CloudRecoService, image: io.BytesIO | BinaryIO, 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 e16eff555..db6d05fd8 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -1,33 +1,47 @@ -""" -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 io # noqa: TC003 import secrets +import time import uuid +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 vws import VWS, CloudRecoService -from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError +from mock_vws.database import CloudDatabase + +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.""" @staticmethod @pytest.mark.parametrize( @@ -36,16 +50,14 @@ class TestAddTarget: ) @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) def test_add_target( + *, vws_client: VWS, image: io.BytesIO | BinaryIO, application_metadata: bytes | None, cloud_reco_client: CloudRecoService, - *, active_flag: bool, ) -> None: - """ - No exception is raised when adding one target. - """ + """No exception is raised when adding one target.""" name = "x" width = 1 if application_metadata is None: @@ -80,10 +92,12 @@ def test_add_target( @staticmethod def test_add_two_targets( + *, vws_client: VWS, 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. """ @@ -97,21 +111,143 @@ def test_add_two_targets( ) +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.""" @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" 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, @@ -126,20 +262,35 @@ def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: 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( + *, vws_client: VWS, 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", width=1, @@ -158,18 +309,15 @@ def test_list_targets( class TestDelete: - """ - Test for deleting a target. - """ + """Test for deleting a target.""" @staticmethod def test_delete_target( + *, vws_client: VWS, 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", width=1, @@ -185,17 +333,16 @@ 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( + *, vws_client: VWS, 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" target_name = uuid.uuid4().hex @@ -240,14 +387,12 @@ def test_get_target_summary_report( class TestGetDatabaseSummaryReport: - """ - Tests for getting a summary report for a database. - """ + """Tests for getting a summary report for a database.""" @staticmethod def test_get_target(vws_client: VWS) -> None: - """ - Details of a database are returned by ``get_database_summary_report``. + """Details of a database are returned by + ``get_database_summary_report``. """ report = vws_client.get_database_summary_report() @@ -287,18 +432,15 @@ def test_get_target(vws_client: VWS) -> None: class TestGetTargetRecord: - """ - Tests for getting a record of a target. - """ + """Tests for getting a record of a target.""" @staticmethod def test_get_target_record( + *, vws_client: VWS, 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", width=1, @@ -341,12 +483,11 @@ def test_get_target_record( @staticmethod def test_get_failed( + *, vws_client: VWS, image_file_failed_state: io.BytesIO, ) -> None: - """ - Check that the report works with a failed target. - """ + """Check that the report works with a failed target.""" target_id = vws_client.add_target( name="x", width=1, @@ -362,18 +503,15 @@ def test_get_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( + *, vws_client: VWS, 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", width=1, @@ -391,12 +529,10 @@ def test_wait_for_target_processed( def test_default_seconds_between_requests( 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, @@ -443,12 +579,12 @@ def test_default_seconds_between_requests( def test_custom_seconds_between_requests( 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, @@ -493,12 +629,10 @@ def test_custom_seconds_between_requests( @staticmethod def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None: - """ - It is possible to set a maximum timeout. - """ + """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, @@ -531,18 +665,15 @@ def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None: class TestGetDuplicateTargets: - """ - Tests for getting duplicate targets. - """ + """Tests for getting duplicate targets.""" @staticmethod def test_get_duplicate_targets( + *, vws_client: VWS, 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", width=1, @@ -565,20 +696,17 @@ def test_get_duplicate_targets( class TestUpdateTarget: - """ - Tests for updating a target. - """ + """Tests for updating a target.""" @staticmethod def test_update_target( + *, vws_client: VWS, 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 = secrets.choice(seq=range(1, 5000)) / 100 target_id = vws_client.add_target( @@ -632,12 +760,11 @@ def test_update_target( @staticmethod def test_no_fields_given( + *, vws_client: VWS, 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", width=1, @@ -647,3 +774,272 @@ def test_no_fields_given( ) 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 86da66040..5afb6639e 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -1,6 +1,4 @@ -""" -Tests for VWS exceptions. -""" +"""Tests for VWS exceptions.""" import io import uuid @@ -8,25 +6,32 @@ 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 import VWS, VuMarkService from vws.exceptions.base_exceptions import VWSError from vws.exceptions.custom_exceptions import ( ServerError, ) from vws.exceptions.vws_exceptions import ( AuthenticationFailureError, + AuthorizationFailedError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + LicenseCheckFailedError, MetadataTooLargeError, ProjectHasNoAPIAccessError, ProjectInactiveError, ProjectSuspendedError, + QuotaExceededError, RequestQuotaReachedError, RequestTimeTooSkewedError, TargetNameExistError, @@ -35,14 +40,19 @@ 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 | 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(expected_exception=ImageTooLargeError) as exc: @@ -59,7 +69,8 @@ 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" @@ -69,9 +80,15 @@ def test_invalid_given_id(vws_client: VWS) -> None: 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, a ``ServerError`` exception is + When a name with a bad character is given, a ``ServerError`` + exception + is raised. """ max_char_value = 65535 @@ -91,15 +108,73 @@ def test_add_bad_name(vws_client: VWS, high_quality_image: io.BytesIO) -> None: 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( @@ -120,9 +195,7 @@ 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. - """ + """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( @@ -137,11 +210,14 @@ 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 + A ``TargetNameExist`` exception is raised after adding two targets + with + the same name. """ vws_client.add_target( @@ -168,12 +244,13 @@ def test_project_inactive( high_quality_image: io.BytesIO, ) -> None: """ - A ``ProjectInactive`` exception is raised if adding a target to an inactive + A ``ProjectInactive`` exception is raised if adding a target to 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) vws_client = VWS( server_access_key=database.server_access_key, server_secret_key=database.server_secret_key, @@ -192,11 +269,13 @@ def test_project_inactive( 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( @@ -215,11 +294,13 @@ 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(expected_exception=MetadataTooLargeError) as exc: @@ -235,11 +316,13 @@ def test_metadata_too_large( 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( @@ -273,11 +356,13 @@ 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, @@ -285,7 +370,7 @@ def test_authentication_failure( ) with MockVWS() as mock: - mock.add_database(database=database) + mock.add_cloud_database(cloud_database=database) with pytest.raises( expected_exception=AuthenticationFailureError @@ -302,11 +387,13 @@ def test_authentication_failure( 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( @@ -325,19 +412,24 @@ 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 = [ AuthenticationFailureError, + AuthorizationFailedError, BadImageError, + BadRequestError, DateRangeError, FailError, ImageTooLargeError, + InvalidAcceptHeaderError, + InvalidInstanceIdError, + InvalidTargetTypeError, + LicenseCheckFailedError, MetadataTooLargeError, ProjectInactiveError, ProjectHasNoAPIAccessError, ProjectSuspendedError, + QuotaExceededError, RequestQuotaReachedError, RequestTimeTooSkewedError, TargetNameExistError, @@ -350,13 +442,112 @@ def test_vwsexception_inheritance() -> None: 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. """ + 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") @@ -369,3 +560,118 @@ def test_base_exception( 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