├── .git_archival.txt ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── dependabot-merge.yml │ ├── lint.yml │ ├── publish-site.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.rst ├── CODE_OF_CONDUCT.rst ├── LICENSE ├── Makefile ├── README.rst ├── codecov.yaml ├── conftest.py ├── docs ├── Makefile └── source │ ├── __init__.py │ ├── api-reference.rst │ ├── changelog.rst │ ├── conf.py │ ├── contributing.rst │ ├── exceptions.rst │ ├── index.rst │ └── release-process.rst ├── pyproject.toml ├── spelling_private_dict.txt ├── src └── vws │ ├── __init__.py │ ├── exceptions │ ├── __init__.py │ ├── base_exceptions.py │ ├── cloud_reco_exceptions.py │ ├── custom_exceptions.py │ └── vws_exceptions.py │ ├── include_target_data.py │ ├── py.typed │ ├── query.py │ ├── reports.py │ ├── response.py │ └── vws.py └── tests ├── __init__.py ├── conftest.py ├── test_cloud_reco_exceptions.py ├── test_query.py ├── test_vws.py └── test_vws_exceptions.py /.git_archival.txt: -------------------------------------------------------------------------------- 1 | ref-names: HEAD -> main 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | updates: 5 | - package-ecosystem: pip 6 | directory: / 7 | schedule: 8 | interval: daily 9 | open-pull-requests-limit: 10 10 | - package-ecosystem: github-actions 11 | directory: / 12 | schedule: 13 | interval: daily 14 | open-pull-requests-limit: 10 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Test 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | schedule: 11 | # * is a special character in YAML so you have to quote this string 12 | # Run at 1:00 every day 13 | - cron: 0 1 * * * 14 | 15 | jobs: 16 | build: 17 | 18 | strategy: 19 | matrix: 20 | python-version: ['3.13'] 21 | platform: [ubuntu-latest, windows-latest] 22 | 23 | runs-on: ${{ matrix.platform }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Install uv 29 | uses: astral-sh/setup-uv@v6 30 | with: 31 | enable-cache: true 32 | cache-dependency-glob: '**/pyproject.toml' 33 | 34 | - name: Run tests 35 | run: | 36 | # We run tests against "." and not the tests directory as we test the README 37 | # and documentation. 38 | uv run --extra=dev --python=${{ matrix.python-version }} pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests/ . --cov-report=xml 39 | 40 | - name: Upload coverage to Codecov 41 | uses: codecov/codecov-action@v5 42 | with: 43 | fail_ci_if_error: true 44 | token: ${{ secrets.CODECOV_TOKEN }} 45 | 46 | completion-ci: 47 | needs: build 48 | runs-on: ubuntu-latest 49 | if: always() # Run even if one matrix job fails 50 | steps: 51 | - name: Check matrix job status 52 | run: |- 53 | if ! ${{ needs.build.result == 'success' }}; then 54 | echo "One or more matrix jobs failed" 55 | exit 1 56 | fi 57 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-merge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Dependabot auto-merge 4 | on: pull_request 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | jobs: 11 | dependabot: 12 | runs-on: ubuntu-latest 13 | if: github.actor == 'dependabot[bot]' 14 | steps: 15 | - name: Dependabot metadata 16 | id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: ${{ secrets.GITHUB_TOKEN }} 20 | - name: Enable auto-merge for Dependabot PRs 21 | run: gh pr merge --auto --merge "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Lint 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | schedule: 11 | # * is a special character in YAML so you have to quote this string 12 | # Run at 1:00 every day 13 | - cron: 0 1 * * * 14 | 15 | jobs: 16 | build: 17 | 18 | strategy: 19 | matrix: 20 | python-version: ['3.13'] 21 | platform: [ubuntu-latest, windows-latest] 22 | 23 | runs-on: ${{ matrix.platform }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Install uv 29 | uses: astral-sh/setup-uv@v6 30 | with: 31 | enable-cache: true 32 | cache-dependency-glob: '**/pyproject.toml' 33 | 34 | - name: Lint 35 | run: | 36 | uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose 37 | uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose 38 | uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose 39 | env: 40 | UV_PYTHON: ${{ matrix.python-version }} 41 | 42 | - uses: pre-commit-ci/lite-action@v1.1.0 43 | if: always() 44 | 45 | completion-lint: 46 | needs: build 47 | runs-on: ubuntu-latest 48 | if: always() # Run even if one matrix job fails 49 | steps: 50 | - name: Check matrix job status 51 | run: |- 52 | if ! ${{ needs.build.result == 'success' }}; then 53 | echo "One or more matrix jobs failed" 54 | exit 1 55 | fi 56 | -------------------------------------------------------------------------------- /.github/workflows/publish-site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deploy documentation 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | pages: 12 | runs-on: ubuntu-latest 13 | environment: 14 | name: ${{ github.ref_name == 'main' && 'github-pages' || 'development' }} 15 | url: ${{ steps.deployment.outputs.page_url }} 16 | permissions: 17 | pages: write 18 | id-token: write 19 | steps: 20 | - id: deployment 21 | uses: sphinx-notes/pages@v3 22 | with: 23 | documentation_path: docs/source 24 | pyproject_extras: dev 25 | python_version: '3.13' 26 | sphinx_build_options: -W 27 | cache: true 28 | publish: ${{ github.ref_name == 'main' }} 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Release 4 | 5 | on: workflow_dispatch 6 | 7 | jobs: 8 | build: 9 | name: Publish a release 10 | runs-on: ubuntu-latest 11 | 12 | # Specifying an environment is strongly recommended by PyPI. 13 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. 14 | environment: release 15 | 16 | permissions: 17 | # This is needed for PyPI publishing. 18 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. 19 | id-token: write 20 | # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. 21 | contents: write 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | # See 27 | # https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches 28 | token: ${{ secrets.RELEASE_PAT }} 29 | # Fetch all history including tags. 30 | # Needed to find the latest tag. 31 | # 32 | # Also, avoids 33 | # https://github.com/stefanzweifel/git-auto-commit-action/issues/99. 34 | fetch-depth: 0 35 | 36 | - name: Install uv 37 | uses: astral-sh/setup-uv@v6 38 | with: 39 | enable-cache: true 40 | cache-dependency-glob: '**/pyproject.toml' 41 | 42 | - name: Calver calculate version 43 | uses: StephaneBour/actions-calver@master 44 | id: calver 45 | with: 46 | date_format: '%Y.%m.%d' 47 | release: false 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Get the changelog underline 52 | id: changelog_underline 53 | run: | 54 | underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')" 55 | echo "underline=${underline}" >> "$GITHUB_OUTPUT" 56 | 57 | - name: Update changelog 58 | uses: jacobtomlinson/gha-find-replace@v3 59 | with: 60 | find: "Next\n----" 61 | replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ 62 | \ }}" 63 | include: CHANGELOG.rst 64 | regex: false 65 | 66 | - uses: stefanzweifel/git-auto-commit-action@v5 67 | id: commit 68 | with: 69 | commit_message: Bump CHANGELOG 70 | file_pattern: CHANGELOG.rst 71 | # Error if there are no changes. 72 | skip_dirty_check: true 73 | 74 | - name: Bump version and push tag 75 | id: tag_version 76 | uses: mathieudutour/github-tag-action@v6.2 77 | with: 78 | github_token: ${{ secrets.GITHUB_TOKEN }} 79 | custom_tag: ${{ steps.calver.outputs.release }} 80 | tag_prefix: '' 81 | commit_sha: ${{ steps.commit.outputs.commit_hash }} 82 | 83 | - name: Create a GitHub release 84 | uses: ncipollo/release-action@v1 85 | with: 86 | tag: ${{ steps.tag_version.outputs.new_tag }} 87 | makeLatest: true 88 | name: Release ${{ steps.tag_version.outputs.new_tag }} 89 | body: ${{ steps.tag_version.outputs.changelog }} 90 | 91 | - name: Build a binary wheel and a source tarball 92 | run: | 93 | git fetch --tags 94 | git checkout ${{ steps.tag_version.outputs.new_tag }} 95 | uv build --sdist --wheel --out-dir dist/ 96 | uv run --extra=release check-wheel-contents dist/*.whl 97 | 98 | - name: Publish distribution 📦 to PyPI 99 | # We use PyPI trusted publishing rather than a PyPI API token. 100 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. 101 | uses: pypa/gh-action-pypi-publish@release/v1 102 | with: 103 | verbose: true 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | .venv/ 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Files containing secrets 94 | vuforia_secrets.env 95 | ci_secrets/ 96 | secrets.tar 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | # Ignore Mac DS_Store files 102 | .DS_Store 103 | **/.DS_Store 104 | 105 | # pyre 106 | .pyre/ 107 | 108 | # pytest 109 | .pytest_cache/ 110 | 111 | # setuptools_scm 112 | src/*/_setuptools_scm_version.txt 113 | 114 | uv.lock 115 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fail_fast: true 3 | 4 | # See https://pre-commit.com for more information 5 | # See https://pre-commit.com/hooks.html for more hooks 6 | 7 | ci: 8 | # We use system Python, with required dependencies specified in pyproject.toml. 9 | # We therefore cannot use those dependencies in pre-commit CI. 10 | skip: 11 | - actionlint 12 | - sphinx-lint 13 | - check-manifest 14 | - deptry 15 | - doc8 16 | - docformatter 17 | - docs 18 | - interrogate 19 | - interrogate-docs 20 | - linkcheck 21 | - mypy 22 | - mypy-docs 23 | - pylint 24 | - pyproject-fmt-fix 25 | - pyright 26 | - pyright-docs 27 | - pyright-verifytypes 28 | - pyroma 29 | - ruff-check-fix 30 | - ruff-check-fix-docs 31 | - ruff-format-fix 32 | - ruff-format-fix-docs 33 | - shellcheck 34 | - shellcheck-docs 35 | - shfmt 36 | - shfmt-docs 37 | - spelling 38 | - vulture 39 | - vulture-docs 40 | - yamlfix 41 | 42 | default_install_hook_types: [pre-commit, pre-push, commit-msg] 43 | 44 | repos: 45 | - repo: meta 46 | hooks: 47 | - id: check-useless-excludes 48 | - repo: https://github.com/pre-commit/pre-commit-hooks 49 | rev: v5.0.0 50 | hooks: 51 | - id: check-added-large-files 52 | - id: check-case-conflict 53 | - id: check-executables-have-shebangs 54 | - id: check-merge-conflict 55 | - id: check-shebang-scripts-are-executable 56 | - id: check-symlinks 57 | - id: check-json 58 | - id: check-toml 59 | - id: check-vcs-permalinks 60 | - id: check-yaml 61 | - id: end-of-file-fixer 62 | - id: file-contents-sorter 63 | files: spelling_private_dict\.txt$ 64 | - id: trailing-whitespace 65 | - repo: https://github.com/pre-commit/pygrep-hooks 66 | rev: v1.10.0 67 | hooks: 68 | - id: rst-directive-colons 69 | - id: rst-inline-touching-normal 70 | - id: text-unicode-replacement-char 71 | - id: rst-backticks 72 | 73 | - repo: local 74 | hooks: 75 | - id: actionlint 76 | name: actionlint 77 | entry: uv run --extra=dev actionlint 78 | language: python 79 | pass_filenames: false 80 | types_or: [yaml] 81 | additional_dependencies: [uv==0.6.3] 82 | 83 | - id: docformatter 84 | name: docformatter 85 | entry: uv run --extra=dev -m docformatter --in-place 86 | language: python 87 | types_or: [python] 88 | additional_dependencies: [uv==0.6.3] 89 | 90 | - id: shellcheck 91 | name: shellcheck 92 | entry: uv run --extra=dev shellcheck --shell=bash 93 | language: python 94 | types_or: [shell] 95 | additional_dependencies: [uv==0.6.3] 96 | 97 | - id: shellcheck-docs 98 | name: shellcheck-docs 99 | entry: uv run --extra=dev doccmd --language=shell --language=console --command="shellcheck 100 | --shell=bash" 101 | language: python 102 | types_or: [markdown, rst] 103 | additional_dependencies: [uv==0.6.3] 104 | 105 | - id: shfmt 106 | name: shfmt 107 | entry: uv run --extra=dev shfmt --write --space-redirects --indent=4 108 | language: python 109 | types_or: [shell] 110 | additional_dependencies: [uv==0.6.3] 111 | 112 | - id: shfmt-docs 113 | name: shfmt-docs 114 | entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt 115 | --no-pad-file --command="shfmt --write --space-redirects --indent=4" 116 | language: python 117 | types_or: [markdown, rst] 118 | additional_dependencies: [uv==0.6.3] 119 | 120 | - id: mypy 121 | name: mypy 122 | stages: [pre-push] 123 | entry: uv run --extra=dev -m mypy 124 | language: python 125 | types_or: [python, toml] 126 | pass_filenames: false 127 | additional_dependencies: [uv==0.6.3] 128 | 129 | - id: mypy-docs 130 | name: mypy-docs 131 | stages: [pre-push] 132 | entry: uv run --extra=dev doccmd --language=python --command="mypy" 133 | language: python 134 | types_or: [markdown, rst] 135 | additional_dependencies: [uv==0.6.3] 136 | 137 | - id: check-manifest 138 | name: check-manifest 139 | stages: [pre-push] 140 | entry: uv run --extra=dev -m check_manifest 141 | language: python 142 | pass_filenames: false 143 | additional_dependencies: [uv==0.6.3] 144 | 145 | - id: pyright 146 | name: pyright 147 | stages: [pre-push] 148 | entry: uv run --extra=dev -m pyright . 149 | language: python 150 | types_or: [python, toml] 151 | pass_filenames: false 152 | additional_dependencies: [uv==0.6.3] 153 | 154 | - id: pyright-docs 155 | name: pyright-docs 156 | stages: [pre-push] 157 | entry: uv run --extra=dev doccmd --language=python --command="pyright" 158 | language: python 159 | types_or: [markdown, rst] 160 | additional_dependencies: [uv==0.6.3] 161 | 162 | - id: vulture 163 | name: vulture 164 | entry: uv run --extra=dev -m vulture . 165 | language: python 166 | types_or: [python] 167 | pass_filenames: false 168 | additional_dependencies: [uv==0.6.3] 169 | 170 | - id: vulture-docs 171 | name: vulture docs 172 | entry: uv run --extra=dev doccmd --language=python --command="vulture" 173 | language: python 174 | types_or: [markdown, rst] 175 | additional_dependencies: [uv==0.6.3] 176 | 177 | - id: pyroma 178 | name: pyroma 179 | entry: uv run --extra=dev -m pyroma --min 10 . 180 | language: python 181 | pass_filenames: false 182 | types_or: [toml] 183 | additional_dependencies: [uv==0.6.3] 184 | 185 | - id: deptry 186 | name: deptry 187 | entry: uv run --extra=dev -m deptry src/ 188 | language: python 189 | pass_filenames: false 190 | additional_dependencies: [uv==0.6.3] 191 | 192 | - id: pylint 193 | name: pylint 194 | entry: uv run --extra=dev -m pylint *.py src/ tests/ docs/ 195 | language: python 196 | stages: [manual] 197 | pass_filenames: false 198 | additional_dependencies: [uv==0.6.3] 199 | 200 | - id: pylint-docs 201 | name: pylint-docs 202 | entry: uv run --extra=dev doccmd --language=python --command="pylint" 203 | language: python 204 | stages: [manual] 205 | types_or: [markdown, rst] 206 | additional_dependencies: [uv==0.6.3] 207 | 208 | - id: ruff-check-fix 209 | name: Ruff check fix 210 | entry: uv run --extra=dev -m ruff check --fix 211 | language: python 212 | types_or: [python] 213 | additional_dependencies: [uv==0.6.3] 214 | 215 | - id: ruff-check-fix-docs 216 | name: Ruff check fix docs 217 | entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix" 218 | language: python 219 | types_or: [markdown, rst] 220 | additional_dependencies: [uv==0.6.3] 221 | 222 | - id: ruff-format-fix 223 | name: Ruff format 224 | entry: uv run --extra=dev -m ruff format 225 | language: python 226 | types_or: [python] 227 | additional_dependencies: [uv==0.6.3] 228 | 229 | - id: ruff-format-fix-docs 230 | name: Ruff format docs 231 | entry: uv run --extra=dev doccmd --language=python --no-pad-file --command="ruff 232 | format" 233 | language: python 234 | types_or: [markdown, rst] 235 | additional_dependencies: [uv==0.6.3] 236 | 237 | - id: doc8 238 | name: doc8 239 | entry: uv run --extra=dev -m doc8 240 | language: python 241 | types_or: [rst] 242 | additional_dependencies: [uv==0.6.3] 243 | 244 | - id: interrogate 245 | name: interrogate 246 | entry: uv run --extra=dev -m interrogate 247 | language: python 248 | types_or: [python] 249 | additional_dependencies: [uv==0.6.3] 250 | 251 | - id: interrogate-docs 252 | name: interrogate docs 253 | entry: uv run --extra=dev doccmd --language=python --command="interrogate" 254 | language: python 255 | types_or: [markdown, rst] 256 | additional_dependencies: [uv==0.6.3] 257 | 258 | - id: pyproject-fmt-fix 259 | name: pyproject-fmt 260 | entry: uv run --extra=dev pyproject-fmt 261 | language: python 262 | types_or: [toml] 263 | files: pyproject.toml 264 | additional_dependencies: [uv==0.6.3] 265 | 266 | - id: linkcheck 267 | name: linkcheck 268 | entry: make -C docs/ linkcheck SPHINXOPTS=-W 269 | language: python 270 | types_or: [rst] 271 | stages: [manual] 272 | pass_filenames: false 273 | additional_dependencies: [uv==0.6.3] 274 | 275 | - id: spelling 276 | name: spelling 277 | entry: make -C docs/ spelling SPHINXOPTS=-W 278 | language: python 279 | types_or: [rst] 280 | stages: [manual] 281 | pass_filenames: false 282 | additional_dependencies: [uv==0.6.3] 283 | 284 | - id: docs 285 | name: Build Documentation 286 | entry: make docs 287 | language: python 288 | stages: [manual] 289 | pass_filenames: false 290 | additional_dependencies: [uv==0.6.3] 291 | 292 | - id: pyright-verifytypes 293 | name: pyright-verifytypes 294 | stages: [pre-push] 295 | entry: uv run --extra=dev -m pyright --verifytypes vws 296 | language: python 297 | pass_filenames: false 298 | types_or: [python] 299 | additional_dependencies: [uv==0.6.3] 300 | 301 | - id: yamlfix 302 | name: yamlfix 303 | entry: uv run --extra=dev yamlfix 304 | language: python 305 | types_or: [yaml] 306 | additional_dependencies: [uv==0.6.3] 307 | 308 | - id: sphinx-lint 309 | name: sphinx-lint 310 | entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long 311 | language: python 312 | types_or: [rst] 313 | additional_dependencies: [uv==0.6.3] 314 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "ms-python.python" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": "explicit" 5 | }, 6 | "editor.defaultFormatter": "charliermarsh.ruff", 7 | "editor.formatOnSave": true 8 | }, 9 | "esbonio.sphinx.confDir": "", 10 | "python.testing.pytestArgs": [ 11 | "." 12 | ], 13 | "python.testing.unittestEnabled": false, 14 | "python.testing.pytestEnabled": true 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Next 5 | ---- 6 | 7 | 2025.03.10.1 8 | ------------ 9 | 10 | 2025.03.10 11 | ---------- 12 | 13 | * Removed ``vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName`` which now does not occur in VWS. 14 | 15 | 2024.09.21 16 | ------------ 17 | 18 | 2024.09.04.1 19 | ------------ 20 | 21 | 2024.09.04 22 | ------------ 23 | 24 | * Move ``Response`` from ``vws.exceptions.response`` to ``vws.types``. 25 | * Add ``raw`` field to ``Response``. 26 | 27 | 2024.09.03 28 | ------------ 29 | 30 | * Make ``VWS.make_request`` a public method. 31 | 32 | 2024.09.02 33 | ------------ 34 | 35 | * Breaking change: Exception names now end with ``Error``. 36 | * Use a timeout (30 seconds) when making requests to the VWS API. 37 | * Type hint changes: images are now ``io.BytesIO`` instances or ``io.BufferedRandom``. 38 | 39 | 2024.02.19 40 | ------------ 41 | 42 | * Add exception response attribute to ``vws.exceptions.custom_exceptions.RequestEntityTooLarge``. 43 | 44 | 2024.02.06 45 | ------------ 46 | 47 | * Exception response attributes are now ``vws.exceptions.response.Response`` instances rather than ``requests.Response`` objects. 48 | 49 | 2024.02.04.1 50 | ------------ 51 | 52 | 2024.02.04 53 | ------------ 54 | 55 | * Return a new error (``vws.custom_exceptions.ServerError``) when the server returns a 5xx status code. 56 | 57 | 2023.12.27 58 | ------------ 59 | 60 | * Breaking change: The ``vws.exceptions.cloud_reco_exceptions.UnknownVWSErrorPossiblyBadName`` is now ``vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName``. 61 | * ``vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName`` now has a ``response`` parameter and attribute. 62 | 63 | 2023.12.26 64 | ------------ 65 | 66 | 2023.05.21 67 | ------------ 68 | 69 | * Breaking change: the ``vws.exceptions.custom_exceptions.ActiveMatchingTargetsDeleteProcessing`` exception has been removed as Vuforia no longer returns this error. 70 | 71 | 2023.03.25 72 | ------------ 73 | 74 | * Support file-like objects in every method which accepts a file. 75 | 76 | 2023.03.05 77 | ------------ 78 | 79 | 2021.03.28.2 80 | ------------ 81 | 82 | 2021.03.28.1 83 | ------------ 84 | 85 | 2021.03.28.0 86 | ------------ 87 | 88 | * Breaking change: The ``vws.exceptions.cloud_reco_exceptions.MatchProcessing`` is now ``vws.exceptions.custom_exceptions.ActiveMatchingTargetsDeleteProcessing``. 89 | * Added new exception ``vws.exceptions.custom_exceptions.RequestEntityTooLarge``. 90 | * Add better exception handling when querying a server which does not serve the Vuforia API. 91 | 92 | 2020.09.07.0 93 | ------------ 94 | 95 | * Breaking change: Move exceptions and create base exceptions. 96 | It is now possible to, for example, catch 97 | ``vws.exceptions.base_exceptions.VWSException`` to catch many of the 98 | exceptions raised by the ``VWS`` client. 99 | Credit to ``@laymonage`` for this change. 100 | 101 | 2020.08.21.0 102 | ------------ 103 | 104 | * Change the return type of ``vws_client.get_target_record`` to match what is returned by the web API. 105 | 106 | 2020.06.19.0 107 | ------------ 108 | 109 | 2020.03.21.0 110 | ------------ 111 | 112 | * Add Windows support. 113 | 114 | 2019.11.23.0 115 | ------------ 116 | 117 | * Make ``active_flag`` and ``application_metadata`` required on ``add_target``. 118 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | ==================================== 3 | 4 | Our Pledge 5 | ---------- 6 | 7 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 8 | 9 | Our Standards 10 | ------------- 11 | 12 | Examples of behavior that contributes to creating a positive environment include: 13 | 14 | Using welcoming and inclusive language 15 | Being respectful of differing viewpoints and experiences 16 | Gracefully accepting constructive criticism 17 | Focusing on what is best for the community 18 | Showing empathy towards other community members 19 | Examples of unacceptable behavior by participants include: 20 | 21 | The use of sexualized language or imagery and unwelcome sexual attention or advances 22 | Trolling, insulting/derogatory comments, and personal or political attacks 23 | Public or private harassment 24 | Publishing others' private information, such as a physical or electronic address, without explicit permission 25 | Other conduct which could reasonably be considered inappropriate in a professional setting 26 | Our Responsibilities 27 | 28 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 29 | 30 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 31 | 32 | Scope 33 | ----- 34 | 35 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 36 | 37 | Enforcement 38 | ----------- 39 | 40 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at adamdangoor@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 41 | 42 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 43 | 44 | Attribution 45 | ----------- 46 | 47 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4. 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash -euxo pipefail 2 | 3 | # Treat Sphinx warnings as errors 4 | SPHINXOPTS := -W 5 | 6 | .PHONY: docs 7 | docs: 8 | make -C docs clean html SPHINXOPTS=$(SPHINXOPTS) 9 | 10 | .PHONY: open-docs 11 | open-docs: 12 | python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))' 13 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |Build Status| |codecov| |PyPI| 2 | 3 | vws-python 4 | ========== 5 | 6 | Python library for the Vuforia Web Services (VWS) API and the Vuforia 7 | Web Query API. 8 | 9 | Installation 10 | ------------ 11 | 12 | .. code-block:: shell 13 | 14 | pip install vws-python 15 | 16 | This is tested on Python |minimum-python-version|\+. Get in touch with 17 | ``adamdangoor@gmail.com`` if you would like to use this with another 18 | language. 19 | 20 | Getting Started 21 | --------------- 22 | 23 | .. code-block:: python 24 | 25 | """Add a target to VWS and then query it.""" 26 | 27 | import os 28 | import pathlib 29 | import uuid 30 | 31 | from vws import VWS, CloudRecoService 32 | 33 | server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] 34 | server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] 35 | client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] 36 | client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] 37 | 38 | vws_client = VWS( 39 | server_access_key=server_access_key, 40 | server_secret_key=server_secret_key, 41 | ) 42 | 43 | cloud_reco_client = CloudRecoService( 44 | client_access_key=client_access_key, 45 | client_secret_key=client_secret_key, 46 | ) 47 | 48 | name = "my_image_name_" + uuid.uuid4().hex 49 | 50 | image = pathlib.Path("high_quality_image.jpg") 51 | with image.open(mode="rb") as my_image_file: 52 | target_id = vws_client.add_target( 53 | name=name, 54 | width=1, 55 | image=my_image_file, 56 | active_flag=True, 57 | application_metadata=None, 58 | ) 59 | 60 | vws_client.wait_for_target_processed(target_id=target_id) 61 | 62 | with image.open(mode="rb") as my_image_file: 63 | matching_targets = cloud_reco_client.query(image=my_image_file) 64 | 65 | assert matching_targets[0].target_id == target_id 66 | 67 | Full Documentation 68 | ------------------ 69 | 70 | See the `full documentation `__. 71 | 72 | .. |Build Status| image:: https://github.com/VWS-Python/vws-python/actions/workflows/ci.yml/badge.svg?branch=main 73 | :target: https://github.com/VWS-Python/vws-python/actions 74 | .. |codecov| image:: https://codecov.io/gh/VWS-Python/vws-python/branch/main/graph/badge.svg 75 | :target: https://codecov.io/gh/VWS-Python/vws-python 76 | .. |PyPI| image:: https://badge.fury.io/py/VWS-Python.svg 77 | :target: https://badge.fury.io/py/VWS-Python 78 | .. |minimum-python-version| replace:: 3.13 79 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | # Require 100% test coverage. 7 | target: 100% 8 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup for Sybil. 3 | """ 4 | 5 | import io 6 | import uuid 7 | from collections.abc import Generator 8 | from doctest import ELLIPSIS 9 | from pathlib import Path 10 | 11 | import pytest 12 | from beartype import beartype 13 | from mock_vws import MockVWS 14 | from mock_vws.database import VuforiaDatabase 15 | from sybil import Sybil 16 | from sybil.parsers.rest import ( 17 | ClearNamespaceParser, 18 | DocTestParser, 19 | PythonCodeBlockParser, 20 | ) 21 | 22 | 23 | def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: 24 | """ 25 | Apply the beartype decorator to all collected test functions. 26 | """ 27 | for item in items: 28 | if isinstance(item, pytest.Function): 29 | item.obj = beartype(obj=item.obj) 30 | 31 | 32 | @pytest.fixture(name="make_image_file") 33 | def fixture_make_image_file( 34 | high_quality_image: io.BytesIO, 35 | ) -> Generator[None]: 36 | """Make an image file available in the test directory. 37 | 38 | The path of this file matches the path in the documentation. 39 | """ 40 | new_image = Path("high_quality_image.jpg") 41 | buffer = high_quality_image.getvalue() 42 | new_image.write_bytes(data=buffer) 43 | yield 44 | new_image.unlink() 45 | 46 | 47 | @pytest.fixture(name="mock_vws") 48 | def fixture_mock_vws( 49 | monkeypatch: pytest.MonkeyPatch, 50 | ) -> Generator[None]: 51 | """Yield a mock VWS. 52 | 53 | The keys used here match the keys in the documentation. 54 | """ 55 | server_access_key = uuid.uuid4().hex 56 | server_secret_key = uuid.uuid4().hex 57 | client_access_key = uuid.uuid4().hex 58 | client_secret_key = uuid.uuid4().hex 59 | 60 | database = VuforiaDatabase( 61 | server_access_key=server_access_key, 62 | server_secret_key=server_secret_key, 63 | client_access_key=client_access_key, 64 | client_secret_key=client_secret_key, 65 | ) 66 | 67 | monkeypatch.setenv(name="VWS_SERVER_ACCESS_KEY", value=server_access_key) 68 | monkeypatch.setenv(name="VWS_SERVER_SECRET_KEY", value=server_secret_key) 69 | monkeypatch.setenv(name="VWS_CLIENT_ACCESS_KEY", value=client_access_key) 70 | monkeypatch.setenv(name="VWS_CLIENT_SECRET_KEY", value=client_secret_key) 71 | # We use a low processing time so that tests run quickly. 72 | with MockVWS(processing_time_seconds=0.2) as mock: 73 | mock.add_database(database=database) 74 | yield 75 | 76 | 77 | pytest_collect_file = Sybil( 78 | parsers=[ 79 | ClearNamespaceParser(), 80 | DocTestParser(optionflags=ELLIPSIS), 81 | PythonCodeBlockParser(), 82 | ], 83 | patterns=["*.rst", "*.py"], 84 | fixtures=["make_image_file", "mock_vws"], 85 | ).pytest() 86 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = VWSPYTHON 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @uv run --extra=dev $(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @uv run --extra=dev $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Documentation. 3 | """ 4 | -------------------------------------------------------------------------------- /docs/source/api-reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. automodule:: vws 5 | :undoc-members: 6 | :members: 7 | 8 | .. automodule:: vws.reports 9 | :undoc-members: 10 | :members: 11 | 12 | .. automodule:: vws.include_target_data 13 | :undoc-members: 14 | :members: 15 | 16 | .. automodule:: vws.response 17 | :undoc-members: 18 | :members: 19 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Configuration for Sphinx. 4 | """ 5 | 6 | import importlib.metadata 7 | from pathlib import Path 8 | 9 | from packaging.specifiers import SpecifierSet 10 | from sphinx_pyproject import SphinxConfig 11 | 12 | _pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" 13 | _pyproject_config = SphinxConfig( 14 | pyproject_file=_pyproject_file, 15 | config_overrides={"version": None}, 16 | ) 17 | 18 | project = _pyproject_config.name 19 | author = _pyproject_config.author 20 | 21 | extensions = [ 22 | "sphinx_copybutton", 23 | "sphinx.ext.autodoc", 24 | "sphinx.ext.intersphinx", 25 | "sphinx.ext.napoleon", 26 | "sphinx_substitution_extensions", 27 | "sphinxcontrib.spelling", 28 | ] 29 | 30 | templates_path = ["_templates"] 31 | source_suffix = ".rst" 32 | master_doc = "index" 33 | 34 | project_copyright = f"%Y, {author}" 35 | 36 | # Exclude the prompt from copied code with sphinx_copybutton. 37 | # https://sphinx-copybutton.readthedocs.io/en/latest/use.html#automatic-exclusion-of-prompts-from-the-copies. 38 | copybutton_exclude = ".linenos, .gp" 39 | 40 | project_metadata = importlib.metadata.metadata(distribution_name=project) 41 | requires_python = project_metadata["Requires-Python"] 42 | specifiers = SpecifierSet(specifiers=requires_python) 43 | (specifier,) = specifiers 44 | if specifier.operator != ">=": 45 | msg = ( 46 | f"We only support '>=' for Requires-Python, got {specifier.operator}." 47 | ) 48 | raise ValueError(msg) 49 | minimum_python_version = specifier.version 50 | 51 | language = "en" 52 | 53 | # The name of the syntax highlighting style to use. 54 | pygments_style = "sphinx" 55 | 56 | html_theme = "furo" 57 | html_title = project 58 | html_show_copyright = False 59 | html_show_sphinx = False 60 | html_show_sourcelink = False 61 | html_theme_options = { 62 | "sidebar_hide_name": False, 63 | } 64 | 65 | # Output file base name for HTML help builder. 66 | htmlhelp_basename = "VWSPYTHONdoc" 67 | intersphinx_mapping = { 68 | "python": (f"https://docs.python.org/{minimum_python_version}", None), 69 | } 70 | nitpicky = True 71 | nitpick_ignore = (("py:class", "_io.BytesIO"),) 72 | warning_is_error = True 73 | 74 | autoclass_content = "both" 75 | 76 | # Retry link checking to avoid transient network errors. 77 | linkcheck_retries = 5 78 | 79 | spelling_word_list_filename = "../../spelling_private_dict.txt" 80 | 81 | autodoc_member_order = "bysource" 82 | 83 | rst_prolog = f""" 84 | .. |project| replace:: {project} 85 | .. |minimum-python-version| replace:: {minimum_python_version} 86 | .. |github-owner| replace:: VWS-Python 87 | .. |github-repository| replace:: vws-python 88 | """ 89 | -------------------------------------------------------------------------------- /docs/source/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing to |project| 2 | ========================= 3 | 4 | Contributions to this repository must pass tests and linting. 5 | 6 | CI is the canonical source of truth. 7 | 8 | Install contribution dependencies 9 | --------------------------------- 10 | 11 | Install Python dependencies in a virtual environment. 12 | 13 | .. code-block:: console 14 | 15 | $ pip install --editable '.[dev]' 16 | 17 | Spell checking requires ``enchant``. 18 | This can be installed on macOS, for example, with `Homebrew`_: 19 | 20 | .. code-block:: console 21 | 22 | $ brew install enchant 23 | 24 | and on Ubuntu with ``apt``: 25 | 26 | .. code-block:: console 27 | 28 | $ apt-get install -y enchant 29 | 30 | Install ``pre-commit`` hooks: 31 | 32 | .. code-block:: console 33 | 34 | $ pre-commit install 35 | 36 | Linting 37 | ------- 38 | 39 | Run lint tools either by committing, or with: 40 | 41 | .. code-block:: console 42 | 43 | $ pre-commit run --all-files --hook-stage pre-commit --verbose 44 | $ pre-commit run --all-files --hook-stage pre-push --verbose 45 | $ pre-commit run --all-files --hook-stage manual --verbose 46 | 47 | .. _Homebrew: https://brew.sh 48 | 49 | Running tests 50 | ------------- 51 | 52 | Run ``pytest``: 53 | 54 | .. code-block:: console 55 | 56 | $ pytest 57 | 58 | Documentation 59 | ------------- 60 | 61 | Documentation is built on Read the Docs. 62 | 63 | Run the following commands to build and view documentation locally: 64 | 65 | .. code-block:: console 66 | 67 | $ make docs 68 | $ make open-docs 69 | 70 | Continuous integration 71 | ---------------------- 72 | 73 | Tests are run on GitHub Actions. 74 | The configuration for this is in :file:`.github/workflows/`. 75 | 76 | Performing a release 77 | -------------------- 78 | 79 | See :doc:`release-process`. 80 | -------------------------------------------------------------------------------- /docs/source/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | Base exceptions 5 | --------------- 6 | 7 | .. automodule:: vws.exceptions.base_exceptions 8 | :members: 9 | :show-inheritance: 10 | :inherited-members: Exception 11 | :exclude-members: errno, filename, filename2, strerror 12 | 13 | VWS exceptions 14 | -------------- 15 | 16 | .. automodule:: vws.exceptions.vws_exceptions 17 | :members: 18 | :show-inheritance: 19 | :inherited-members: Exception 20 | :exclude-members: errno, filename, filename2, strerror 21 | 22 | CloudRecoService exceptions 23 | --------------------------- 24 | 25 | .. automodule:: vws.exceptions.cloud_reco_exceptions 26 | :members: 27 | :show-inheritance: 28 | :inherited-members: Exception 29 | :exclude-members: errno, filename, filename2, strerror 30 | 31 | Custom exceptions 32 | ----------------- 33 | 34 | .. automodule:: vws.exceptions.custom_exceptions 35 | :members: 36 | :show-inheritance: 37 | :inherited-members: Exception 38 | :exclude-members: errno, filename, filename2, strerror 39 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | |project| 2 | ========= 3 | 4 | Installation 5 | ------------ 6 | 7 | .. code-block:: console 8 | 9 | $ pip install vws-python 10 | 11 | This is tested on Python |minimum-python-version|\+. 12 | Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. 13 | 14 | Usage 15 | ----- 16 | 17 | See the :doc:`api-reference` for full usage details. 18 | 19 | .. code-block:: python 20 | 21 | """Add a target to VWS and then query it.""" 22 | 23 | import os 24 | import pathlib 25 | import uuid 26 | 27 | from vws import VWS, CloudRecoService 28 | 29 | server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] 30 | server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] 31 | client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] 32 | client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] 33 | 34 | vws_client = VWS( 35 | server_access_key=server_access_key, 36 | server_secret_key=server_secret_key, 37 | ) 38 | 39 | cloud_reco_client = CloudRecoService( 40 | client_access_key=client_access_key, 41 | client_secret_key=client_secret_key, 42 | ) 43 | 44 | name = "my_image_name_" + uuid.uuid4().hex 45 | 46 | image = pathlib.Path("high_quality_image.jpg") 47 | with image.open(mode="rb") as my_image_file: 48 | target_id = vws_client.add_target( 49 | name=name, 50 | width=1, 51 | image=my_image_file, 52 | active_flag=True, 53 | application_metadata=None, 54 | ) 55 | 56 | vws_client.wait_for_target_processed(target_id=target_id) 57 | 58 | with image.open(mode="rb") as my_image_file: 59 | matching_targets = cloud_reco_client.query(image=my_image_file) 60 | 61 | assert matching_targets[0].target_id == target_id 62 | 63 | Testing 64 | ------- 65 | 66 | To write unit tests for code which uses this library, without using your Vuforia quota, you can use the `VWS Python Mock`_ tool: 67 | 68 | .. code-block:: console 69 | 70 | $ pip install vws-python-mock 71 | 72 | .. clear-namespace 73 | 74 | .. code-block:: python 75 | 76 | """Add a target to VWS and then query it.""" 77 | 78 | import pathlib 79 | 80 | from mock_vws import MockVWS 81 | from mock_vws.database import VuforiaDatabase 82 | 83 | from vws import VWS, CloudRecoService 84 | 85 | with MockVWS() as mock: 86 | database = VuforiaDatabase() 87 | mock.add_database(database=database) 88 | vws_client = VWS( 89 | server_access_key=database.server_access_key, 90 | server_secret_key=database.server_secret_key, 91 | ) 92 | cloud_reco_client = CloudRecoService( 93 | client_access_key=database.client_access_key, 94 | client_secret_key=database.client_secret_key, 95 | ) 96 | 97 | image = pathlib.Path("high_quality_image.jpg") 98 | with image.open(mode="rb") as my_image_file: 99 | target_id = vws_client.add_target( 100 | name="example_image_name", 101 | width=1, 102 | image=my_image_file, 103 | application_metadata=None, 104 | active_flag=True, 105 | ) 106 | 107 | vws_client.wait_for_target_processed(target_id=target_id) 108 | matching_targets = cloud_reco_client.query(image=my_image_file) 109 | 110 | assert matching_targets[0].target_id == target_id 111 | 112 | There are some differences between the mock and the real Vuforia. 113 | See https://vws-python.github.io/vws-python-mock/differences-to-vws for details. 114 | 115 | .. _VWS Python Mock: https://github.com/VWS-Python/vws-python-mock 116 | 117 | Reference 118 | --------- 119 | 120 | .. toctree:: 121 | :maxdepth: 3 122 | 123 | api-reference 124 | exceptions 125 | contributing 126 | release-process 127 | changelog 128 | -------------------------------------------------------------------------------- /docs/source/release-process.rst: -------------------------------------------------------------------------------- 1 | Release Process 2 | =============== 3 | 4 | Outcomes 5 | ~~~~~~~~ 6 | 7 | * A new ``git`` tag available to install. 8 | * A new package on PyPI. 9 | 10 | Perform a Release 11 | ~~~~~~~~~~~~~~~~~ 12 | 13 | #. `Install GitHub CLI`_. 14 | 15 | #. Perform a release: 16 | 17 | .. code-block:: console 18 | :substitutions: 19 | 20 | $ gh workflow run release.yml --repo "|github-owner|/|github-repository|" 21 | 22 | .. _Install GitHub CLI: https://cli.github.com/ 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools", 5 | "setuptools-scm>=8.1.0", 6 | ] 7 | 8 | [project] 9 | name = "vws-python" 10 | description = "Interact with the Vuforia Web Services (VWS) API." 11 | readme = { file = "README.rst", content-type = "text/x-rst" } 12 | keywords = [ 13 | "client", 14 | "vuforia", 15 | "vws", 16 | ] 17 | license = { file = "LICENSE" } 18 | authors = [ 19 | { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, 20 | ] 21 | requires-python = ">=3.13" 22 | classifiers = [ 23 | "Development Status :: 5 - Production/Stable", 24 | "Environment :: Web Environment", 25 | "License :: OSI Approved :: MIT License", 26 | "Operating System :: Microsoft :: Windows", 27 | "Operating System :: POSIX", 28 | "Programming Language :: Python :: 3 :: Only", 29 | "Programming Language :: Python :: 3.13", 30 | ] 31 | dynamic = [ 32 | "version", 33 | ] 34 | dependencies = [ 35 | "beartype>=0.18.5", 36 | "requests>=2.32.3", 37 | "urllib3>=2.2.3", 38 | "vws-auth-tools>=2024.7.12", 39 | ] 40 | optional-dependencies.dev = [ 41 | "actionlint-py==1.7.7.23", 42 | "check-manifest==0.50", 43 | "deptry==0.23.0", 44 | "doc8==1.1.2", 45 | "doccmd==2025.4.8", 46 | "docformatter==1.7.7", 47 | "freezegun==1.5.2", 48 | "furo==2024.8.6", 49 | "interrogate==1.7.0", 50 | "mypy[faster-cache]==1.16.0", 51 | "mypy-strict-kwargs==2025.4.3", 52 | "pre-commit==4.2.0", 53 | "pydocstyle==6.3", 54 | "pyenchant==3.3.0rc1", 55 | "pygments==2.19.1", 56 | "pylint==3.3.7", 57 | "pylint-per-file-ignores==1.4.0", 58 | "pyproject-fmt==2.6.0", 59 | "pyright==1.1.401", 60 | "pyroma==4.2", 61 | "pytest==8.4.0", 62 | "pytest-cov==6.1.1", 63 | "pyyaml==6.0.2", 64 | "ruff==0.11.13", 65 | # We add shellcheck-py not only for shell scripts and shell code blocks, 66 | # but also because having it installed means that ``actionlint-py`` will 67 | # use it to lint shell commands in GitHub workflow files. 68 | "shellcheck-py==0.10.0.1", 69 | "shfmt-py==3.11.0.2", 70 | "sphinx==8.2.3", 71 | "sphinx-copybutton==0.5.2", 72 | "sphinx-lint==1.0.0", 73 | "sphinx-pyproject==0.3.0", 74 | "sphinx-substitution-extensions==2025.4.3", 75 | "sphinxcontrib-spelling==8.0.1", 76 | "sybil==9.1.0", 77 | "types-requests==2.32.0.20250602", 78 | "vulture==2.14", 79 | "vws-python-mock==2025.3.10.1", 80 | "vws-test-fixtures==2023.3.5", 81 | "yamlfix==1.17.0", 82 | ] 83 | optional-dependencies.release = [ "check-wheel-contents==0.6.2" ] 84 | urls.Documentation = "https://vws-python.github.io/vws-python/" 85 | urls.Source = "https://github.com/VWS-Python/vws-python" 86 | 87 | [tool.setuptools] 88 | zip-safe = false 89 | 90 | [tool.setuptools.packages.find] 91 | where = [ 92 | "src", 93 | ] 94 | 95 | [tool.setuptools.package-data] 96 | vws = [ 97 | "py.typed", 98 | ] 99 | 100 | [tool.distutils.bdist_wheel] 101 | universal = true 102 | 103 | [tool.setuptools_scm] 104 | 105 | # This keeps the start of the version the same as the last release. 106 | # This is useful for our documentation to include e.g. binary links 107 | # to the latest released binary. 108 | # 109 | # Code to match this is in ``conf.py``. 110 | version_scheme = "post-release" 111 | 112 | [tool.ruff] 113 | line-length = 79 114 | 115 | lint.select = [ 116 | "ALL", 117 | ] 118 | lint.ignore = [ 119 | # Ruff warns that this conflicts with the formatter. 120 | "COM812", 121 | # Allow our chosen docstring line-style - no one-line summary. 122 | "D200", 123 | "D205", 124 | "D212", 125 | # Ruff warns that this conflicts with the formatter. 126 | "ISC001", 127 | # Ignore "too-many-*" errors as they seem to get in the way more than 128 | # helping. 129 | "PLR0913", 130 | ] 131 | 132 | lint.per-file-ignores."doccmd_*.py" = [ 133 | # Allow asserts in docs. 134 | "S101", 135 | ] 136 | 137 | lint.per-file-ignores."docs/source/*.py" = [ 138 | # Allow asserts in docs. 139 | "S101", 140 | ] 141 | 142 | lint.per-file-ignores."tests/*.py" = [ 143 | # Allow asserts in tests. 144 | "S101", 145 | ] 146 | 147 | # Do not automatically remove commented out code. 148 | # We comment out code during development, and with VSCode auto-save, this code 149 | # is sometimes annoyingly removed. 150 | lint.unfixable = [ 151 | "ERA001", 152 | ] 153 | lint.pydocstyle.convention = "google" 154 | 155 | [tool.pylint] 156 | 157 | [tool.pylint.'MASTER'] 158 | 159 | # Pickle collected data for later comparisons. 160 | persistent = true 161 | 162 | # Use multiple processes to speed up Pylint. 163 | jobs = 0 164 | 165 | # List of plugins (as comma separated values of python modules names) to load, 166 | # usually to register additional checkers. 167 | # See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. 168 | # and we also add `pylint_per_file_ignores` to allow per-file ignores. 169 | # We do not use the plugins: 170 | # - pylint.extensions.code_style 171 | # - pylint.extensions.magic_value 172 | # - pylint.extensions.while_used 173 | # as they seemed to get in the way. 174 | load-plugins = [ 175 | "pylint_per_file_ignores", 176 | 'pylint.extensions.bad_builtin', 177 | 'pylint.extensions.comparison_placement', 178 | 'pylint.extensions.consider_refactoring_into_while_condition', 179 | 'pylint.extensions.docparams', 180 | 'pylint.extensions.dunder', 181 | 'pylint.extensions.eq_without_hash', 182 | 'pylint.extensions.for_any_all', 183 | 'pylint.extensions.mccabe', 184 | 'pylint.extensions.no_self_use', 185 | 'pylint.extensions.overlapping_exceptions', 186 | 'pylint.extensions.private_import', 187 | 'pylint.extensions.redefined_loop_name', 188 | 'pylint.extensions.redefined_variable_type', 189 | 'pylint.extensions.set_membership', 190 | 'pylint.extensions.typing', 191 | ] 192 | 193 | # Allow loading of arbitrary C extensions. Extensions are imported into the 194 | # active Python interpreter and may run arbitrary code. 195 | unsafe-load-any-extension = false 196 | 197 | [tool.pylint.'MESSAGES CONTROL'] 198 | 199 | # Enable the message, report, category or checker with the given id(s). You can 200 | # either give multiple identifier separated by comma (,) or put this option 201 | # multiple time (only on the command line, not in the configuration file where 202 | # it should appear only once). See also the "--disable" option for examples. 203 | enable = [ 204 | 'bad-inline-option', 205 | 'deprecated-pragma', 206 | 'file-ignored', 207 | 'spelling', 208 | 'use-symbolic-message-instead', 209 | 'useless-suppression', 210 | ] 211 | 212 | # Disable the message, report, category or checker with the given id(s). You 213 | # can either give multiple identifiers separated by comma (,) or put this 214 | # option multiple times (only on the command line, not in the configuration 215 | # file where it should appear only once).You can also use "--disable=all" to 216 | # disable everything first and then reenable specific checks. For example, if 217 | # you want to run only the similarities checker, you can use "--disable=all 218 | # --enable=similarities". If you want to run only the classes checker, but have 219 | # no Warning level messages displayed, use"--disable=all --enable=classes 220 | # --disable=W" 221 | 222 | disable = [ 223 | 'too-few-public-methods', 224 | 'too-many-locals', 225 | 'too-many-arguments', 226 | 'too-many-instance-attributes', 227 | 'too-many-return-statements', 228 | 'too-many-lines', 229 | 'locally-disabled', 230 | # Let ruff handle long lines 231 | 'line-too-long', 232 | # Let ruff handle unused imports 233 | 'unused-import', 234 | # Let ruff deal with sorting 235 | 'ungrouped-imports', 236 | # We don't need everything to be documented because of mypy 237 | 'missing-type-doc', 238 | 'missing-return-type-doc', 239 | # Too difficult to please 240 | 'duplicate-code', 241 | # Let ruff handle imports 242 | 'wrong-import-order', 243 | # mypy does not want untyped parameters. 244 | 'useless-type-doc', 245 | ] 246 | 247 | # We ignore invalid names because: 248 | # - We want to use generated module names, which may not be valid, but are never seen. 249 | # - We want to use global variables in documentation, which may not be uppercase. 250 | # - conf.py is a Sphinx configuration file which requires lowercase global variable names. 251 | per-file-ignores = [ 252 | "docs/:invalid-name", 253 | "doccmd_README_rst.*.py:invalid-name", 254 | ] 255 | 256 | [tool.pylint.'FORMAT'] 257 | 258 | # Allow the body of an if to be on the same line as the test if there is no 259 | # else. 260 | single-line-if-stmt = false 261 | 262 | [tool.pylint.'SPELLING'] 263 | 264 | # Spelling dictionary name. Available dictionaries: none. To make it working 265 | # install python-enchant package. 266 | spelling-dict = 'en_US' 267 | 268 | # A path to a file that contains private dictionary; one word per line. 269 | spelling-private-dict-file = 'spelling_private_dict.txt' 270 | 271 | # Tells whether to store unknown words to indicated private dictionary in 272 | # --spelling-private-dict-file option instead of raising a message. 273 | spelling-store-unknown-words = 'no' 274 | 275 | [tool.docformatter] 276 | make-summary-multi-line = true 277 | 278 | [tool.check-manifest] 279 | 280 | ignore = [ 281 | ".checkmake-config.ini", 282 | ".yamlfmt", 283 | "*.enc", 284 | ".pre-commit-config.yaml", 285 | "CHANGELOG.rst", 286 | "CODE_OF_CONDUCT.rst", 287 | "CONTRIBUTING.rst", 288 | "LICENSE", 289 | "Makefile", 290 | "ci", 291 | "ci/**", 292 | "codecov.yaml", 293 | "doc8.ini", 294 | "docs", 295 | "docs/**", 296 | ".git_archival.txt", 297 | "spelling_private_dict.txt", 298 | "tests", 299 | "tests-pylintrc", 300 | "tests/**", 301 | "vuforia_secrets.env.example", 302 | "lint.mk", 303 | ] 304 | 305 | [tool.deptry] 306 | pep621_dev_dependency_groups = [ 307 | "dev", 308 | "release", 309 | ] 310 | 311 | [tool.pyproject-fmt] 312 | indent = 4 313 | keep_full_version = true 314 | max_supported_python = "3.13" 315 | 316 | [tool.pytest.ini_options] 317 | 318 | xfail_strict = true 319 | log_cli = true 320 | 321 | [tool.coverage.run] 322 | 323 | branch = true 324 | 325 | [tool.coverage.report] 326 | exclude_also = [ 327 | "if TYPE_CHECKING:", 328 | ] 329 | 330 | [tool.mypy] 331 | 332 | strict = true 333 | files = [ "." ] 334 | exclude = [ "build" ] 335 | follow_untyped_imports = true 336 | plugins = [ 337 | "mypy_strict_kwargs", 338 | ] 339 | 340 | [tool.pyright] 341 | 342 | enableTypeIgnoreComments = false 343 | reportUnnecessaryTypeIgnoreComment = true 344 | typeCheckingMode = "strict" 345 | 346 | [tool.interrogate] 347 | fail-under = 100 348 | omit-covered-files = true 349 | verbose = 2 350 | 351 | [tool.doc8] 352 | 353 | max_line_length = 2000 354 | ignore_path = [ 355 | "./.eggs", 356 | "./docs/build", 357 | "./docs/build/spelling/output.txt", 358 | "./node_modules", 359 | "./src/*.egg-info/", 360 | "./src/*/_setuptools_scm_version.txt", 361 | ] 362 | 363 | [tool.vulture] 364 | # Ideally we would limit the paths to the source code where we want to ignore names, 365 | # but Vulture does not enable this. 366 | ignore_names = [ 367 | # pytest configuration 368 | "pytest_collect_file", 369 | "pytest_collection_modifyitems", 370 | "pytest_plugins", 371 | # pytest fixtures - we name fixtures like this for this purpose 372 | "fixture_*", 373 | # Sphinx 374 | "autoclass_content", 375 | "autoclass_content", 376 | "autodoc_member_order", 377 | "copybutton_exclude", 378 | "extensions", 379 | "html_show_copyright", 380 | "html_show_sourcelink", 381 | "html_show_sphinx", 382 | "html_theme", 383 | "html_theme_options", 384 | "html_title", 385 | "htmlhelp_basename", 386 | "intersphinx_mapping", 387 | "language", 388 | "linkcheck_ignore", 389 | "linkcheck_retries", 390 | "master_doc", 391 | "nitpicky", 392 | "nitpick_ignore", 393 | "project_copyright", 394 | "pygments_style", 395 | "rst_prolog", 396 | "source_suffix", 397 | "spelling_word_list_filename", 398 | "templates_path", 399 | "warning_is_error", 400 | ] 401 | 402 | # Duplicate some of .gitignore 403 | exclude = [ ".venv" ] 404 | 405 | [tool.yamlfix] 406 | section_whitelines = 1 407 | whitelines = 1 408 | -------------------------------------------------------------------------------- /spelling_private_dict.txt: -------------------------------------------------------------------------------- 1 | AuthenticationFailure 2 | BadImage 3 | ConnectionErrorPossiblyImageTooLarge 4 | DateRangeError 5 | ImageTooLarge 6 | InactiveProject 7 | JSONDecodeError 8 | MatchProcessing 9 | MaxNumResultsOutOfRange 10 | MetadataTooLarge 11 | OopsAnErrorOccurredPossiblyBadName 12 | OopsAnErrorOccurredPossiblyBadNameError 13 | ProjectHasNoAPIAccess 14 | ProjectInactive 15 | ProjectSuspended 16 | RequestQuotaReached 17 | RequestTimeTooSkewed 18 | TargetNameExist 19 | TargetProcessingTimeout 20 | TargetQuotaReached 21 | TargetStatusNotSuccess 22 | TargetStatusProcessing 23 | TooManyRequests 24 | Ubuntu 25 | UnknownTarget 26 | admin 27 | api 28 | args 29 | ascii 30 | beartype 31 | bool 32 | boolean 33 | bytesio 34 | changelog 35 | chunked 36 | cmyk 37 | connectionerror 38 | customizable 39 | dataclasses 40 | datetime 41 | decodable 42 | dev 43 | dict 44 | docstring 45 | filename 46 | foo 47 | formdata 48 | github 49 | grayscale 50 | greyscale 51 | hexdigits 52 | hmac 53 | html 54 | http 55 | https 56 | iff 57 | io 58 | issuecomment 59 | jpeg 60 | json 61 | keyring 62 | kib 63 | kwargs 64 | linters 65 | linting 66 | login 67 | macOS 68 | mb 69 | metadata 70 | mib 71 | mockvws 72 | multipart 73 | noqa 74 | plugins 75 | png 76 | pragma 77 | py 78 | pyright 79 | pytest 80 | readme 81 | readthedocs 82 | recognitions 83 | refactoring 84 | regex 85 | reimplementation 86 | reportMissingTypeStubs 87 | reportUnknownVariableType 88 | rfc 89 | rgb 90 | str 91 | timestamp 92 | todo 93 | traceback 94 | travis 95 | txt 96 | unmocked 97 | untyped 98 | url 99 | usefixtures 100 | validators 101 | vuforia 102 | vuforia's 103 | vwq 104 | vws 105 | xxx 106 | yml 107 | -------------------------------------------------------------------------------- /src/vws/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A library for Vuforia Web Services. 3 | """ 4 | 5 | from .query import CloudRecoService 6 | from .vws import VWS 7 | 8 | __all__ = [ 9 | "VWS", 10 | "CloudRecoService", 11 | ] 12 | -------------------------------------------------------------------------------- /src/vws/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom exceptions raised by this package. 3 | """ 4 | -------------------------------------------------------------------------------- /src/vws/exceptions/base_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base exceptions for errors returned by Vuforia Web Services or the Vuforia 3 | Cloud Recognition Web API. 4 | """ 5 | 6 | from beartype import beartype 7 | 8 | from vws.response import Response 9 | 10 | 11 | @beartype 12 | class CloudRecoError(Exception): 13 | """ 14 | Base class for Vuforia Cloud Recognition Web API exceptions. 15 | """ 16 | 17 | def __init__(self, response: Response) -> None: 18 | """ 19 | Args: 20 | response: The response to a request to Vuforia. 21 | """ 22 | super().__init__(response.text) 23 | self._response = response 24 | 25 | @property 26 | def response(self) -> Response: 27 | """ 28 | The response returned by Vuforia which included this error. 29 | """ 30 | return self._response 31 | 32 | 33 | @beartype 34 | class VWSError(Exception): 35 | """Base class for Vuforia Web Services errors. 36 | 37 | These errors are defined at 38 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. 39 | """ 40 | 41 | def __init__(self, response: Response) -> None: 42 | """ 43 | Args: 44 | response: The response to a request to Vuforia. 45 | """ 46 | super().__init__() 47 | self._response = response 48 | 49 | @property 50 | def response(self) -> Response: 51 | """ 52 | The response returned by Vuforia which included this error. 53 | """ 54 | return self._response 55 | -------------------------------------------------------------------------------- /src/vws/exceptions/cloud_reco_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exceptions which match errors raised by the Vuforia Cloud Recognition Web APIs. 3 | """ 4 | 5 | from beartype import beartype 6 | 7 | from vws.exceptions.base_exceptions import CloudRecoError 8 | 9 | 10 | @beartype 11 | class MaxNumResultsOutOfRangeError(CloudRecoError): 12 | """ 13 | Exception raised when the ``max_num_results`` given to the Cloud 14 | Recognition Web API query endpoint is out of range. 15 | """ 16 | 17 | 18 | @beartype 19 | class InactiveProjectError(CloudRecoError): 20 | """ 21 | Exception raised when Vuforia returns a response with a result code 22 | 'InactiveProject'. 23 | """ 24 | 25 | 26 | @beartype 27 | class BadImageError(CloudRecoError): 28 | """ 29 | Exception raised when Vuforia returns a response with a result code 30 | 'BadImage'. 31 | """ 32 | 33 | 34 | @beartype 35 | class AuthenticationFailureError(CloudRecoError): 36 | """ 37 | Exception raised when Vuforia returns a response with a result code 38 | 'AuthenticationFailure'. 39 | """ 40 | 41 | 42 | @beartype 43 | class RequestTimeTooSkewedError(CloudRecoError): 44 | """ 45 | Exception raised when Vuforia returns a response with a result code 46 | 'RequestTimeTooSkewed'. 47 | """ 48 | -------------------------------------------------------------------------------- /src/vws/exceptions/custom_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exceptions which do not map to errors at 3 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes 4 | or simple errors given by the cloud recognition service. 5 | """ 6 | 7 | from beartype import beartype 8 | 9 | from vws.response import Response 10 | 11 | 12 | @beartype 13 | class RequestEntityTooLargeError(Exception): 14 | """ 15 | Exception raised when the given image is too large. 16 | """ 17 | 18 | def __init__(self, response: Response) -> None: 19 | """ 20 | Args: 21 | response: The response returned by Vuforia. 22 | """ 23 | super().__init__(response.text) 24 | self._response = response 25 | 26 | @property 27 | def response(self) -> Response: 28 | """ 29 | The response returned by Vuforia which included this error. 30 | """ 31 | return self._response 32 | 33 | 34 | @beartype 35 | class TargetProcessingTimeoutError(Exception): 36 | """ 37 | Exception raised when waiting for a target to be processed times out. 38 | """ 39 | 40 | 41 | @beartype 42 | class ServerError(Exception): # pragma: no cover 43 | """ 44 | Exception raised when VWS returns a server error. 45 | """ 46 | 47 | def __init__(self, response: Response) -> None: 48 | """ 49 | Args: 50 | response: The response returned by Vuforia. 51 | """ 52 | super().__init__(response.text) 53 | self._response = response 54 | 55 | @property 56 | def response(self) -> Response: 57 | """ 58 | The response returned by Vuforia which included this error. 59 | """ 60 | return self._response 61 | -------------------------------------------------------------------------------- /src/vws/exceptions/vws_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exception raised when Vuforia returns a response with a result code matching 3 | one of those documented at 4 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. 5 | """ 6 | 7 | import json 8 | from urllib.parse import urlparse 9 | 10 | from beartype import beartype 11 | 12 | from vws.exceptions.base_exceptions import VWSError 13 | 14 | 15 | @beartype 16 | class UnknownTargetError(VWSError): 17 | """ 18 | Exception raised when Vuforia returns a response with a result code 19 | 'UnknownTarget'. 20 | """ 21 | 22 | @property 23 | def target_id(self) -> str: 24 | """ 25 | The unknown target ID. 26 | """ 27 | path = urlparse(url=self.response.url).path 28 | # Every HTTP path which can raise this error is in the format 29 | # `/something/{target_id}`. 30 | return path.split(sep="/", maxsplit=2)[-1] 31 | 32 | 33 | @beartype 34 | class FailError(VWSError): 35 | """ 36 | Exception raised when Vuforia returns a response with a result code 'Fail'. 37 | """ 38 | 39 | 40 | @beartype 41 | class BadImageError(VWSError): 42 | """ 43 | Exception raised when Vuforia returns a response with a result code 44 | 'BadImage'. 45 | """ 46 | 47 | 48 | @beartype 49 | class AuthenticationFailureError(VWSError): 50 | """ 51 | Exception raised when Vuforia returns a response with a result code 52 | 'AuthenticationFailure'. 53 | """ 54 | 55 | 56 | # See https://github.com/VWS-Python/vws-python/issues/822. 57 | @beartype 58 | class RequestQuotaReachedError(VWSError): # pragma: no cover 59 | """ 60 | Exception raised when Vuforia returns a response with a result code 61 | 'RequestQuotaReached'. 62 | """ 63 | 64 | 65 | @beartype 66 | class TargetStatusProcessingError(VWSError): 67 | """ 68 | Exception raised when Vuforia returns a response with a result code 69 | 'TargetStatusProcessing'. 70 | """ 71 | 72 | @property 73 | def target_id(self) -> str: 74 | """ 75 | The processing target ID. 76 | """ 77 | path = urlparse(url=self.response.url).path 78 | # Every HTTP path which can raise this error is in the format 79 | # `/something/{target_id}`. 80 | return path.split(sep="/", maxsplit=2)[-1] 81 | 82 | 83 | # This is not simulated by the mock. 84 | @beartype 85 | class DateRangeError(VWSError): # pragma: no cover 86 | """ 87 | Exception raised when Vuforia returns a response with a result code 88 | 'DateRangeError'. 89 | """ 90 | 91 | 92 | # This is not simulated by the mock. 93 | @beartype 94 | class TargetQuotaReachedError(VWSError): # pragma: no cover 95 | """ 96 | Exception raised when Vuforia returns a response with a result code 97 | 'TargetQuotaReached'. 98 | """ 99 | 100 | 101 | # This is not simulated by the mock. 102 | @beartype 103 | class ProjectSuspendedError(VWSError): # pragma: no cover 104 | """ 105 | Exception raised when Vuforia returns a response with a result code 106 | 'ProjectSuspended'. 107 | """ 108 | 109 | 110 | # This is not simulated by the mock. 111 | @beartype 112 | class ProjectHasNoAPIAccessError(VWSError): # pragma: no cover 113 | """ 114 | Exception raised when Vuforia returns a response with a result code 115 | 'ProjectHasNoAPIAccess'. 116 | """ 117 | 118 | 119 | @beartype 120 | class ProjectInactiveError(VWSError): 121 | """ 122 | Exception raised when Vuforia returns a response with a result code 123 | 'ProjectInactive'. 124 | """ 125 | 126 | 127 | @beartype 128 | class MetadataTooLargeError(VWSError): 129 | """ 130 | Exception raised when Vuforia returns a response with a result code 131 | 'MetadataTooLarge'. 132 | """ 133 | 134 | 135 | @beartype 136 | class RequestTimeTooSkewedError(VWSError): 137 | """ 138 | Exception raised when Vuforia returns a response with a result code 139 | 'RequestTimeTooSkewed'. 140 | """ 141 | 142 | 143 | @beartype 144 | class TargetNameExistError(VWSError): 145 | """ 146 | Exception raised when Vuforia returns a response with a result code 147 | 'TargetNameExist'. 148 | """ 149 | 150 | @property 151 | def target_name(self) -> str: 152 | """ 153 | The target name which already exists. 154 | """ 155 | response_body = self.response.request_body or b"" 156 | request_json = json.loads(s=response_body) 157 | return str(object=request_json["name"]) 158 | 159 | 160 | @beartype 161 | class ImageTooLargeError(VWSError): 162 | """ 163 | Exception raised when Vuforia returns a response with a result code 164 | 'ImageTooLarge'. 165 | """ 166 | 167 | 168 | @beartype 169 | class TargetStatusNotSuccessError(VWSError): 170 | """ 171 | Exception raised when Vuforia returns a response with a result code 172 | 'TargetStatusNotSuccess'. 173 | """ 174 | 175 | @property 176 | def target_id(self) -> str: 177 | """ 178 | The unknown target ID. 179 | """ 180 | path = urlparse(url=self.response.url).path 181 | # Every HTTP path which can raise this error is in the format 182 | # `/something/{target_id}`. 183 | return path.split(sep="/", maxsplit=2)[-1] 184 | 185 | 186 | @beartype 187 | class TooManyRequestsError(VWSError): # pragma: no cover 188 | """ 189 | Exception raised when Vuforia returns a response with a result code 190 | 'TooManyRequests'. 191 | """ 192 | -------------------------------------------------------------------------------- /src/vws/include_target_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for managing ``CloudRecoService.query``'s ``include_target_data``. 3 | """ 4 | 5 | from enum import StrEnum, auto, unique 6 | 7 | from beartype import beartype 8 | 9 | 10 | @beartype 11 | @unique 12 | class CloudRecoIncludeTargetData(StrEnum): 13 | """ 14 | Options for the ``include_target_data`` parameter of 15 | ``CloudRecoService.query``. 16 | """ 17 | 18 | TOP = auto() 19 | NONE = auto() 20 | ALL = auto() 21 | -------------------------------------------------------------------------------- /src/vws/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VWS-Python/vws-python/d5dcac9b51aea67e1df1fe6e38e3bf37fe30119e/src/vws/py.typed -------------------------------------------------------------------------------- /src/vws/query.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for interacting with the Vuforia Cloud Recognition Web APIs. 3 | """ 4 | 5 | import datetime 6 | import io 7 | import json 8 | from http import HTTPMethod, HTTPStatus 9 | from typing import Any, BinaryIO 10 | from urllib.parse import urljoin 11 | 12 | import requests 13 | from beartype import beartype 14 | from urllib3.filepost import encode_multipart_formdata 15 | from vws_auth_tools import authorization_header, rfc_1123_date 16 | 17 | from vws.exceptions.cloud_reco_exceptions import ( 18 | AuthenticationFailureError, 19 | BadImageError, 20 | InactiveProjectError, 21 | MaxNumResultsOutOfRangeError, 22 | RequestTimeTooSkewedError, 23 | ) 24 | from vws.exceptions.custom_exceptions import ( 25 | RequestEntityTooLargeError, 26 | ServerError, 27 | ) 28 | from vws.include_target_data import CloudRecoIncludeTargetData 29 | from vws.reports import QueryResult, TargetData 30 | from vws.response import Response 31 | 32 | _ImageType = io.BytesIO | BinaryIO 33 | 34 | 35 | @beartype 36 | def _get_image_data(image: _ImageType) -> bytes: 37 | """ 38 | Get the data of an image file. 39 | """ 40 | original_tell = image.tell() 41 | image.seek(0) 42 | image_data = image.read() 43 | image.seek(original_tell) 44 | return image_data 45 | 46 | 47 | @beartype 48 | class CloudRecoService: 49 | """ 50 | An interface to the Vuforia Cloud Recognition Web APIs. 51 | """ 52 | 53 | def __init__( 54 | self, 55 | client_access_key: str, 56 | client_secret_key: str, 57 | base_vwq_url: str = "https://cloudreco.vuforia.com", 58 | ) -> None: 59 | """ 60 | Args: 61 | client_access_key: A VWS client access key. 62 | client_secret_key: A VWS client secret key. 63 | base_vwq_url: The base URL for the VWQ API. 64 | """ 65 | self._client_access_key = client_access_key 66 | self._client_secret_key = client_secret_key 67 | self._base_vwq_url = base_vwq_url 68 | 69 | def query( 70 | self, 71 | image: _ImageType, 72 | max_num_results: int = 1, 73 | include_target_data: CloudRecoIncludeTargetData = ( 74 | CloudRecoIncludeTargetData.TOP 75 | ), 76 | ) -> list[QueryResult]: 77 | """Use the Vuforia Web Query API to make an Image Recognition Query. 78 | 79 | See 80 | https://developer.vuforia.com/library/web-api/vuforia-query-web-api 81 | for parameter details. 82 | 83 | Args: 84 | image: The image to make a query against. 85 | max_num_results: The maximum number of matching targets to be 86 | returned. 87 | include_target_data: Indicates if target_data records shall be 88 | returned for the matched targets. Accepted values are top 89 | (default value, only return target_data for top ranked match), 90 | none (return no target_data), all (for all matched targets). 91 | 92 | Raises: 93 | ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError: 94 | The client access key pair is not correct. 95 | ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError: 96 | ``max_num_results`` is not within the range (1, 50). 97 | ~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The 98 | project is inactive. 99 | ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError: 100 | There is an error with the time sent to Vuforia. 101 | ~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a 102 | problem with the given image. For example, it must be a JPEG or 103 | PNG file in the grayscale or RGB color space. 104 | ~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The 105 | given image is too large. 106 | ~vws.exceptions.custom_exceptions.ServerError: There is an 107 | error with Vuforia's servers. 108 | 109 | Returns: 110 | An ordered list of target details of matching targets. 111 | """ 112 | image_content = _get_image_data(image=image) 113 | body: dict[str, Any] = { 114 | "image": ("image.jpeg", image_content, "image/jpeg"), 115 | "max_num_results": (None, int(max_num_results), "text/plain"), 116 | "include_target_data": ( 117 | None, 118 | include_target_data.value, 119 | "text/plain", 120 | ), 121 | } 122 | date = rfc_1123_date() 123 | request_path = "/v1/query" 124 | content, content_type_header = encode_multipart_formdata(fields=body) 125 | method = HTTPMethod.POST 126 | 127 | authorization_string = authorization_header( 128 | access_key=self._client_access_key, 129 | secret_key=self._client_secret_key, 130 | method=method, 131 | content=content, 132 | # Note that this is not the actual Content-Type header value sent. 133 | content_type="multipart/form-data", 134 | date=date, 135 | request_path=request_path, 136 | ) 137 | 138 | headers = { 139 | "Authorization": authorization_string, 140 | "Date": date, 141 | "Content-Type": content_type_header, 142 | } 143 | 144 | requests_response = requests.request( 145 | method=method, 146 | url=urljoin(base=self._base_vwq_url, url=request_path), 147 | headers=headers, 148 | data=content, 149 | # We should make the timeout customizable. 150 | timeout=30, 151 | ) 152 | response = Response( 153 | text=requests_response.text, 154 | url=requests_response.url, 155 | status_code=requests_response.status_code, 156 | headers=dict(requests_response.headers), 157 | request_body=requests_response.request.body, 158 | tell_position=requests_response.raw.tell(), 159 | ) 160 | 161 | if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: 162 | raise RequestEntityTooLargeError(response=response) 163 | 164 | if "Integer out of range" in response.text: 165 | raise MaxNumResultsOutOfRangeError(response=response) 166 | 167 | if ( 168 | response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR 169 | ): # pragma: no cover 170 | raise ServerError(response=response) 171 | 172 | result_code = json.loads(s=response.text)["result_code"] 173 | if result_code != "Success": 174 | exception = { 175 | "AuthenticationFailure": AuthenticationFailureError, 176 | "BadImage": BadImageError, 177 | "InactiveProject": InactiveProjectError, 178 | "RequestTimeTooSkewed": RequestTimeTooSkewedError, 179 | }[result_code] 180 | raise exception(response=response) 181 | 182 | result: list[QueryResult] = [] 183 | result_list = list(json.loads(s=response.text)["results"]) 184 | for item in result_list: 185 | target_data: TargetData | None = None 186 | if "target_data" in item: 187 | target_data_dict = item["target_data"] 188 | metadata = target_data_dict["application_metadata"] 189 | timestamp_string = target_data_dict["target_timestamp"] 190 | target_timestamp = datetime.datetime.fromtimestamp( 191 | timestamp=timestamp_string, 192 | tz=datetime.UTC, 193 | ) 194 | target_data = TargetData( 195 | name=target_data_dict["name"], 196 | application_metadata=metadata, 197 | target_timestamp=target_timestamp, 198 | ) 199 | 200 | query_result = QueryResult( 201 | target_id=item["target_id"], 202 | target_data=target_data, 203 | ) 204 | 205 | result.append(query_result) 206 | return result 207 | -------------------------------------------------------------------------------- /src/vws/reports.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classes for representing Vuforia reports. 3 | """ 4 | 5 | import datetime 6 | from dataclasses import dataclass 7 | from enum import Enum, unique 8 | 9 | from beartype import BeartypeConf, beartype 10 | 11 | 12 | @beartype 13 | @dataclass(frozen=True) 14 | class DatabaseSummaryReport: 15 | """A database summary report. 16 | 17 | See 18 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. 19 | """ 20 | 21 | active_images: int 22 | current_month_recos: int 23 | failed_images: int 24 | inactive_images: int 25 | name: str 26 | previous_month_recos: int 27 | processing_images: int 28 | reco_threshold: int 29 | request_quota: int 30 | request_usage: int 31 | target_quota: int 32 | total_recos: int 33 | 34 | 35 | @beartype 36 | @unique 37 | class TargetStatuses(Enum): 38 | """Constants representing VWS target statuses. 39 | 40 | See the 'status' field in 41 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record 42 | """ 43 | 44 | PROCESSING = "processing" 45 | SUCCESS = "success" 46 | FAILED = "failed" 47 | 48 | 49 | @beartype 50 | @dataclass(frozen=True) 51 | class TargetSummaryReport: 52 | """A target summary report. 53 | 54 | See 55 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. 56 | """ 57 | 58 | status: TargetStatuses 59 | database_name: str 60 | target_name: str 61 | upload_date: datetime.date 62 | active_flag: bool 63 | tracking_rating: int 64 | total_recos: int 65 | current_month_recos: int 66 | previous_month_recos: int 67 | 68 | 69 | @beartype(conf=BeartypeConf(is_pep484_tower=True)) 70 | @dataclass(frozen=True) 71 | class TargetRecord: 72 | """A target record. 73 | 74 | See 75 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. 76 | """ 77 | 78 | target_id: str 79 | active_flag: bool 80 | name: str 81 | width: float 82 | tracking_rating: int 83 | reco_rating: str 84 | 85 | 86 | @beartype 87 | @dataclass(frozen=True) 88 | class TargetData: 89 | """ 90 | The target data optionally included with a query match. 91 | """ 92 | 93 | name: str 94 | application_metadata: str | None 95 | target_timestamp: datetime.datetime 96 | 97 | 98 | @beartype 99 | @dataclass(frozen=True) 100 | class QueryResult: 101 | """One query match result. 102 | 103 | See 104 | https://developer.vuforia.com/library/web-api/vuforia-query-web-api. 105 | """ 106 | 107 | target_id: str 108 | target_data: TargetData | None 109 | 110 | 111 | @beartype 112 | @dataclass(frozen=True) 113 | class TargetStatusAndRecord: 114 | """The target status and a target record. 115 | 116 | See 117 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. 118 | """ 119 | 120 | status: TargetStatuses 121 | target_record: TargetRecord 122 | -------------------------------------------------------------------------------- /src/vws/response.py: -------------------------------------------------------------------------------- 1 | """ 2 | Responses for requests to VWS and VWQ. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | 7 | from beartype import beartype 8 | 9 | 10 | @dataclass(frozen=True) 11 | @beartype 12 | class Response: 13 | """ 14 | A response from a request. 15 | """ 16 | 17 | text: str 18 | url: str 19 | status_code: int 20 | headers: dict[str, str] 21 | request_body: bytes | str | None 22 | tell_position: int 23 | -------------------------------------------------------------------------------- /src/vws/vws.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for interacting with Vuforia APIs. 3 | """ 4 | 5 | import base64 6 | import io 7 | import json 8 | import time 9 | from datetime import date 10 | from http import HTTPMethod, HTTPStatus 11 | from typing import BinaryIO 12 | from urllib.parse import urljoin 13 | 14 | import requests 15 | from beartype import BeartypeConf, beartype 16 | from vws_auth_tools import authorization_header, rfc_1123_date 17 | 18 | from vws.exceptions.custom_exceptions import ( 19 | ServerError, 20 | TargetProcessingTimeoutError, 21 | ) 22 | from vws.exceptions.vws_exceptions import ( 23 | AuthenticationFailureError, 24 | BadImageError, 25 | DateRangeError, 26 | FailError, 27 | ImageTooLargeError, 28 | MetadataTooLargeError, 29 | ProjectHasNoAPIAccessError, 30 | ProjectInactiveError, 31 | ProjectSuspendedError, 32 | RequestQuotaReachedError, 33 | RequestTimeTooSkewedError, 34 | TargetNameExistError, 35 | TargetQuotaReachedError, 36 | TargetStatusNotSuccessError, 37 | TargetStatusProcessingError, 38 | TooManyRequestsError, 39 | UnknownTargetError, 40 | ) 41 | from vws.reports import ( 42 | DatabaseSummaryReport, 43 | TargetRecord, 44 | TargetStatusAndRecord, 45 | TargetStatuses, 46 | TargetSummaryReport, 47 | ) 48 | from vws.response import Response 49 | 50 | _ImageType = io.BytesIO | BinaryIO 51 | 52 | 53 | @beartype 54 | def _get_image_data(image: _ImageType) -> bytes: 55 | """ 56 | Get the data of an image file. 57 | """ 58 | original_tell = image.tell() 59 | image.seek(0) 60 | image_data = image.read() 61 | image.seek(original_tell) 62 | return image_data 63 | 64 | 65 | @beartype 66 | def _target_api_request( 67 | *, 68 | content_type: str, 69 | server_access_key: str, 70 | server_secret_key: str, 71 | method: str, 72 | data: bytes, 73 | request_path: str, 74 | base_vws_url: str, 75 | ) -> Response: 76 | """Make a request to the Vuforia Target API. 77 | 78 | This uses `requests` to make a request against https://vws.vuforia.com. 79 | 80 | Args: 81 | content_type: The content type of the request. 82 | server_access_key: A VWS server access key. 83 | server_secret_key: A VWS server secret key. 84 | method: The HTTP method which will be used in the request. 85 | data: The request body which will be used in the request. 86 | request_path: The path to the endpoint which will be used in the 87 | request. 88 | base_vws_url: The base URL for the VWS API. 89 | 90 | Returns: 91 | The response to the request made by `requests`. 92 | """ 93 | date_string = rfc_1123_date() 94 | 95 | signature_string = authorization_header( 96 | access_key=server_access_key, 97 | secret_key=server_secret_key, 98 | method=method, 99 | content=data, 100 | content_type=content_type, 101 | date=date_string, 102 | request_path=request_path, 103 | ) 104 | 105 | headers = { 106 | "Authorization": signature_string, 107 | "Date": date_string, 108 | "Content-Type": content_type, 109 | } 110 | 111 | url = urljoin(base=base_vws_url, url=request_path) 112 | 113 | requests_response = requests.request( 114 | method=method, 115 | url=url, 116 | headers=headers, 117 | data=data, 118 | # We should make the timeout customizable. 119 | timeout=30, 120 | ) 121 | 122 | return Response( 123 | text=requests_response.text, 124 | url=requests_response.url, 125 | status_code=requests_response.status_code, 126 | headers=dict(requests_response.headers), 127 | request_body=requests_response.request.body, 128 | tell_position=requests_response.raw.tell(), 129 | ) 130 | 131 | 132 | @beartype(conf=BeartypeConf(is_pep484_tower=True)) 133 | class VWS: 134 | """ 135 | An interface to Vuforia Web Services APIs. 136 | """ 137 | 138 | def __init__( 139 | self, 140 | server_access_key: str, 141 | server_secret_key: str, 142 | base_vws_url: str = "https://vws.vuforia.com", 143 | ) -> None: 144 | """ 145 | Args: 146 | server_access_key: A VWS server access key. 147 | server_secret_key: A VWS server secret key. 148 | base_vws_url: The base URL for the VWS API. 149 | """ 150 | self._server_access_key = server_access_key 151 | self._server_secret_key = server_secret_key 152 | self._base_vws_url = base_vws_url 153 | 154 | def make_request( 155 | self, 156 | *, 157 | method: str, 158 | data: bytes, 159 | request_path: str, 160 | expected_result_code: str, 161 | content_type: str, 162 | ) -> Response: 163 | """Make a request to the Vuforia Target API. 164 | 165 | This uses `requests` to make a request against Vuforia. 166 | 167 | Args: 168 | method: The HTTP method which will be used in the request. 169 | data: The request body which will be used in the request. 170 | request_path: The path to the endpoint which will be used in the 171 | request. 172 | expected_result_code: See "VWS API Result Codes" on 173 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. 174 | content_type: The content type of the request. 175 | 176 | Returns: 177 | The response to the request made by `requests`. 178 | 179 | Raises: 180 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 181 | with Vuforia's servers. 182 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 183 | rate limiting access. 184 | json.JSONDecodeError: The server did not respond with valid JSON. 185 | This may happen if the server address is not a valid Vuforia 186 | server. 187 | """ 188 | response = _target_api_request( 189 | content_type=content_type, 190 | server_access_key=self._server_access_key, 191 | server_secret_key=self._server_secret_key, 192 | method=method, 193 | data=data, 194 | request_path=request_path, 195 | base_vws_url=self._base_vws_url, 196 | ) 197 | 198 | if ( 199 | response.status_code == HTTPStatus.TOO_MANY_REQUESTS 200 | ): # pragma: no cover 201 | # The Vuforia API returns a 429 response with no JSON body. 202 | raise TooManyRequestsError(response=response) 203 | 204 | if ( 205 | response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR 206 | ): # pragma: no cover 207 | raise ServerError(response=response) 208 | 209 | result_code = json.loads(s=response.text)["result_code"] 210 | 211 | if result_code == expected_result_code: 212 | return response 213 | 214 | exception = { 215 | "AuthenticationFailure": AuthenticationFailureError, 216 | "BadImage": BadImageError, 217 | "DateRangeError": DateRangeError, 218 | "Fail": FailError, 219 | "ImageTooLarge": ImageTooLargeError, 220 | "MetadataTooLarge": MetadataTooLargeError, 221 | "ProjectHasNoAPIAccess": ProjectHasNoAPIAccessError, 222 | "ProjectInactive": ProjectInactiveError, 223 | "ProjectSuspended": ProjectSuspendedError, 224 | "RequestQuotaReached": RequestQuotaReachedError, 225 | "RequestTimeTooSkewed": RequestTimeTooSkewedError, 226 | "TargetNameExist": TargetNameExistError, 227 | "TargetQuotaReached": TargetQuotaReachedError, 228 | "TargetStatusNotSuccess": TargetStatusNotSuccessError, 229 | "TargetStatusProcessing": TargetStatusProcessingError, 230 | "UnknownTarget": UnknownTargetError, 231 | }[result_code] 232 | 233 | raise exception(response=response) 234 | 235 | def add_target( 236 | self, 237 | name: str, 238 | width: float, 239 | image: _ImageType, 240 | application_metadata: str | None, 241 | *, 242 | active_flag: bool, 243 | ) -> str: 244 | """Add a target to a Vuforia Web Services database. 245 | 246 | See 247 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add 248 | for parameter details. 249 | 250 | Args: 251 | name: The name of the target. 252 | width: The width of the target. 253 | image: The image of the target. 254 | active_flag: Whether or not the target is active for query. 255 | application_metadata: The application metadata of the target. 256 | This must be base64 encoded, for example by using:: 257 | 258 | base64.b64encode('input_string').decode('ascii') 259 | 260 | Returns: 261 | The target ID of the new target. 262 | 263 | Raises: 264 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 265 | secret key is not correct. 266 | ~vws.exceptions.vws_exceptions.BadImageError: There is a problem 267 | with the given image. For example, it must be a JPEG or PNG 268 | file in the grayscale or RGB color space. 269 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 270 | the request. For example, the given access key does not match a 271 | known database. 272 | ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given 273 | metadata is too large. The maximum size is 1 MB of data when 274 | Base64 encoded. 275 | ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image 276 | is too large. 277 | ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with 278 | the given ``name`` already exists. 279 | ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is 280 | inactive. 281 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 282 | an error with the time sent to Vuforia. 283 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 284 | with Vuforia's servers. This has been seen to happen when the 285 | given name includes a bad character. 286 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 287 | rate limiting access. 288 | """ 289 | image_data = _get_image_data(image=image) 290 | image_data_encoded = base64.b64encode(s=image_data).decode( 291 | encoding="ascii", 292 | ) 293 | 294 | data = { 295 | "name": name, 296 | "width": width, 297 | "image": image_data_encoded, 298 | "active_flag": active_flag, 299 | "application_metadata": application_metadata, 300 | } 301 | 302 | content = json.dumps(obj=data).encode(encoding="utf-8") 303 | 304 | response = self.make_request( 305 | method=HTTPMethod.POST, 306 | data=content, 307 | request_path="/targets", 308 | expected_result_code="TargetCreated", 309 | content_type="application/json", 310 | ) 311 | 312 | return str(object=json.loads(s=response.text)["target_id"]) 313 | 314 | def get_target_record(self, target_id: str) -> TargetStatusAndRecord: 315 | """Get a given target's target record from the Target Management 316 | System. 317 | 318 | See 319 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. 320 | 321 | Args: 322 | target_id: The ID of the target to get details of. 323 | 324 | Returns: 325 | Response details of a target from Vuforia. 326 | 327 | Raises: 328 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 329 | secret key is not correct. 330 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 331 | the request. For example, the given access key does not match a 332 | known database. 333 | ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target 334 | ID does not match a target in the database. 335 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 336 | an error with the time sent to Vuforia. 337 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 338 | with Vuforia's servers. 339 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 340 | rate limiting access. 341 | """ 342 | response = self.make_request( 343 | method=HTTPMethod.GET, 344 | data=b"", 345 | request_path=f"/targets/{target_id}", 346 | expected_result_code="Success", 347 | content_type="application/json", 348 | ) 349 | 350 | result_data = json.loads(s=response.text) 351 | status = TargetStatuses(value=result_data["status"]) 352 | target_record_dict = dict(result_data["target_record"]) 353 | target_record = TargetRecord( 354 | target_id=target_record_dict["target_id"], 355 | active_flag=target_record_dict["active_flag"], 356 | name=target_record_dict["name"], 357 | width=target_record_dict["width"], 358 | tracking_rating=target_record_dict["tracking_rating"], 359 | reco_rating=target_record_dict["reco_rating"], 360 | ) 361 | return TargetStatusAndRecord( 362 | status=status, 363 | target_record=target_record, 364 | ) 365 | 366 | def wait_for_target_processed( 367 | self, 368 | target_id: str, 369 | seconds_between_requests: float = 0.2, 370 | timeout_seconds: float = 60 * 5, 371 | ) -> None: 372 | """Wait up to five minutes (arbitrary) for a target to get past the 373 | processing stage. 374 | 375 | Args: 376 | target_id: The ID of the target to wait for. 377 | seconds_between_requests: The number of seconds to wait between 378 | requests made while polling the target status. 379 | We wait 0.2 seconds by default, rather than less, than that to 380 | decrease the number of calls made to the API, to decrease the 381 | likelihood of hitting the request quota. 382 | timeout_seconds: The maximum number of seconds to wait for the 383 | target to be processed. 384 | 385 | Raises: 386 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 387 | secret key is not correct. 388 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 389 | the request. For example, the given access key does not match a 390 | known database. 391 | ~vws.exceptions.custom_exceptions.TargetProcessingTimeoutError: The 392 | target remained in the processing stage for more than 393 | ``timeout_seconds`` seconds. 394 | ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target 395 | ID does not match a target in the database. 396 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 397 | an error with the time sent to Vuforia. 398 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 399 | with Vuforia's servers. 400 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 401 | rate limiting access. 402 | """ 403 | start_time = time.monotonic() 404 | while True: 405 | report = self.get_target_summary_report(target_id=target_id) 406 | if report.status != TargetStatuses.PROCESSING: 407 | return 408 | 409 | elapsed_time = time.monotonic() - start_time 410 | if elapsed_time > timeout_seconds: # pragma: no cover 411 | raise TargetProcessingTimeoutError 412 | 413 | time.sleep(seconds_between_requests) 414 | 415 | def list_targets(self) -> list[str]: 416 | """List target IDs. 417 | 418 | See 419 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list. 420 | 421 | Returns: 422 | The IDs of all targets in the database. 423 | 424 | Raises: 425 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 426 | secret key is not correct. 427 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 428 | the request. For example, the given access key does not match a 429 | known database. 430 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 431 | an error with the time sent to Vuforia. 432 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 433 | with Vuforia's servers. 434 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 435 | rate limiting access. 436 | """ 437 | response = self.make_request( 438 | method=HTTPMethod.GET, 439 | data=b"", 440 | request_path="/targets", 441 | expected_result_code="Success", 442 | content_type="application/json", 443 | ) 444 | 445 | return list(json.loads(s=response.text)["results"]) 446 | 447 | def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: 448 | """Get a summary report for a target. 449 | 450 | See 451 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. 452 | 453 | Args: 454 | target_id: The ID of the target to get a summary report for. 455 | 456 | Returns: 457 | Details of the target. 458 | 459 | Raises: 460 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 461 | secret key is not correct. 462 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 463 | the request. For example, the given access key does not match a 464 | known database. 465 | ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target 466 | ID does not match a target in the database. 467 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 468 | an error with the time sent to Vuforia. 469 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 470 | with Vuforia's servers. 471 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 472 | rate limiting access. 473 | """ 474 | response = self.make_request( 475 | method=HTTPMethod.GET, 476 | data=b"", 477 | request_path=f"/summary/{target_id}", 478 | expected_result_code="Success", 479 | content_type="application/json", 480 | ) 481 | 482 | result_data = dict(json.loads(s=response.text)) 483 | return TargetSummaryReport( 484 | status=TargetStatuses(value=result_data["status"]), 485 | database_name=result_data["database_name"], 486 | target_name=result_data["target_name"], 487 | upload_date=date.fromisoformat(result_data["upload_date"]), 488 | active_flag=result_data["active_flag"], 489 | tracking_rating=result_data["tracking_rating"], 490 | total_recos=result_data["total_recos"], 491 | current_month_recos=result_data["current_month_recos"], 492 | previous_month_recos=result_data["previous_month_recos"], 493 | ) 494 | 495 | def get_database_summary_report(self) -> DatabaseSummaryReport: 496 | """Get a summary report for the database. 497 | 498 | See 499 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. 500 | 501 | Returns: 502 | Details of the database. 503 | 504 | Raises: 505 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 506 | secret key is not correct. 507 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 508 | the request. For example, the given access key does not match a 509 | known database. 510 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 511 | an error with the time sent to Vuforia. 512 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 513 | with Vuforia's servers. 514 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 515 | rate limiting access. 516 | """ 517 | response = self.make_request( 518 | method=HTTPMethod.GET, 519 | data=b"", 520 | request_path="/summary", 521 | expected_result_code="Success", 522 | content_type="application/json", 523 | ) 524 | 525 | response_data = dict(json.loads(s=response.text)) 526 | return DatabaseSummaryReport( 527 | active_images=response_data["active_images"], 528 | current_month_recos=response_data["current_month_recos"], 529 | failed_images=response_data["failed_images"], 530 | inactive_images=response_data["inactive_images"], 531 | name=response_data["name"], 532 | previous_month_recos=response_data["previous_month_recos"], 533 | processing_images=response_data["processing_images"], 534 | reco_threshold=response_data["reco_threshold"], 535 | request_quota=response_data["request_quota"], 536 | request_usage=response_data["request_usage"], 537 | target_quota=response_data["target_quota"], 538 | total_recos=response_data["total_recos"], 539 | ) 540 | 541 | def delete_target(self, target_id: str) -> None: 542 | """Delete a given target. 543 | 544 | See 545 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete. 546 | 547 | Args: 548 | target_id: The ID of the target to delete. 549 | 550 | Raises: 551 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 552 | secret key is not correct. 553 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 554 | the request. For example, the given access key does not match a 555 | known database. 556 | ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target 557 | ID does not match a target in the database. 558 | ~vws.exceptions.vws_exceptions.TargetStatusProcessingError: The 559 | given target is in the processing state. 560 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 561 | an error with the time sent to Vuforia. 562 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 563 | with Vuforia's servers. 564 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 565 | rate limiting access. 566 | """ 567 | self.make_request( 568 | method=HTTPMethod.DELETE, 569 | data=b"", 570 | request_path=f"/targets/{target_id}", 571 | expected_result_code="Success", 572 | content_type="application/json", 573 | ) 574 | 575 | def get_duplicate_targets(self, target_id: str) -> list[str]: 576 | """Get targets which may be considered duplicates of a given target. 577 | 578 | See 579 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check. 580 | 581 | Args: 582 | target_id: The ID of the target to delete. 583 | 584 | Returns: 585 | The target IDs of duplicate targets. 586 | 587 | Raises: 588 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 589 | secret key is not correct. 590 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 591 | the request. For example, the given access key does not match a 592 | known database. 593 | ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target 594 | ID does not match a target in the database. 595 | ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is 596 | inactive. 597 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 598 | an error with the time sent to Vuforia. 599 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 600 | with Vuforia's servers. 601 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 602 | rate limiting access. 603 | """ 604 | response = self.make_request( 605 | method=HTTPMethod.GET, 606 | data=b"", 607 | request_path=f"/duplicates/{target_id}", 608 | expected_result_code="Success", 609 | content_type="application/json", 610 | ) 611 | 612 | return list(json.loads(s=response.text)["similar_targets"]) 613 | 614 | def update_target( 615 | self, 616 | *, 617 | target_id: str, 618 | name: str | None = None, 619 | width: float | None = None, 620 | image: _ImageType | None = None, 621 | active_flag: bool | None = None, 622 | application_metadata: str | None = None, 623 | ) -> None: 624 | """Update a target in a Vuforia Web Services database. 625 | 626 | See 627 | https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update 628 | for parameter details. 629 | 630 | Args: 631 | target_id: The ID of the target to update. 632 | name: The name of the target. 633 | width: The width of the target. 634 | image: The image of the target. 635 | active_flag: Whether or not the target is active for query. 636 | application_metadata: The application metadata of the target. 637 | This must be base64 encoded, for example by using:: 638 | 639 | base64.b64encode('input_string').decode('ascii') 640 | 641 | Giving ``None`` will not change the application metadata. 642 | 643 | Raises: 644 | ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The 645 | secret key is not correct. 646 | ~vws.exceptions.vws_exceptions.BadImageError: There is a problem 647 | with the given image. For example, it must be a JPEG or PNG 648 | file in the grayscale or RGB color space. 649 | ~vws.exceptions.vws_exceptions.FailError: There was an error with 650 | the request. For example, the given access key does not match a 651 | known database. 652 | ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given 653 | metadata is too large. The maximum size is 1 MB of data when 654 | Base64 encoded. 655 | ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image 656 | is too large. 657 | ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with 658 | the given ``name`` already exists. 659 | ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is 660 | inactive. 661 | ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is 662 | an error with the time sent to Vuforia. 663 | ~vws.exceptions.custom_exceptions.ServerError: There is an error 664 | with Vuforia's servers. 665 | ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is 666 | rate limiting access. 667 | """ 668 | data: dict[str, str | bool | float | int] = {} 669 | 670 | if name is not None: 671 | data["name"] = name 672 | 673 | if width is not None: 674 | data["width"] = width 675 | 676 | if image is not None: 677 | image_data = _get_image_data(image=image) 678 | image_data_encoded = base64.b64encode(s=image_data).decode( 679 | encoding="ascii", 680 | ) 681 | data["image"] = image_data_encoded 682 | 683 | if active_flag is not None: 684 | data["active_flag"] = active_flag 685 | 686 | if application_metadata is not None: 687 | data["application_metadata"] = application_metadata 688 | 689 | content = json.dumps(obj=data).encode(encoding="utf-8") 690 | 691 | self.make_request( 692 | method=HTTPMethod.PUT, 693 | data=content, 694 | request_path=f"/targets/{target_id}", 695 | expected_result_code="Success", 696 | content_type="application/json", 697 | ) 698 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for ``vws``. 3 | """ 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration, plugins and fixtures for `pytest`. 3 | """ 4 | 5 | import io 6 | from collections.abc import Generator 7 | from pathlib import Path 8 | from typing import BinaryIO, Literal 9 | 10 | import pytest 11 | from mock_vws import MockVWS 12 | from mock_vws.database import VuforiaDatabase 13 | 14 | from vws import VWS, CloudRecoService 15 | 16 | 17 | @pytest.fixture(name="_mock_database") 18 | def fixture_mock_database() -> Generator[VuforiaDatabase]: 19 | """ 20 | Yield a mock ``VuforiaDatabase``. 21 | """ 22 | # We use a low processing time so that tests run quickly. 23 | with MockVWS(processing_time_seconds=0.2) as mock: 24 | database = VuforiaDatabase() 25 | mock.add_database(database=database) 26 | yield database 27 | 28 | 29 | @pytest.fixture 30 | def vws_client(_mock_database: VuforiaDatabase) -> VWS: 31 | """ 32 | A VWS client which connects to a mock database. 33 | """ 34 | return VWS( 35 | server_access_key=_mock_database.server_access_key, 36 | server_secret_key=_mock_database.server_secret_key, 37 | ) 38 | 39 | 40 | @pytest.fixture 41 | def cloud_reco_client(_mock_database: VuforiaDatabase) -> CloudRecoService: 42 | """ 43 | A ``CloudRecoService`` client which connects to a mock database. 44 | """ 45 | return CloudRecoService( 46 | client_access_key=_mock_database.client_access_key, 47 | client_secret_key=_mock_database.client_secret_key, 48 | ) 49 | 50 | 51 | @pytest.fixture(name="image_file", params=["r+b", "rb"]) 52 | def fixture_image_file( 53 | high_quality_image: io.BytesIO, 54 | tmp_path: Path, 55 | request: pytest.FixtureRequest, 56 | ) -> Generator[BinaryIO]: 57 | """ 58 | An image file object. 59 | """ 60 | file = tmp_path / "image.jpg" 61 | buffer = high_quality_image.getvalue() 62 | file.write_bytes(data=buffer) 63 | mode: Literal["r+b", "rb"] = request.param 64 | with file.open(mode=mode) as file_obj: 65 | yield file_obj 66 | 67 | 68 | @pytest.fixture(params=["high_quality_image", "image_file"]) 69 | def image( 70 | request: pytest.FixtureRequest, 71 | high_quality_image: io.BytesIO, 72 | image_file: BinaryIO, 73 | ) -> io.BytesIO | BinaryIO: 74 | """ 75 | An image in any of the types that the API accepts. 76 | """ 77 | if request.param == "high_quality_image": 78 | return high_quality_image 79 | return image_file 80 | -------------------------------------------------------------------------------- /tests/test_cloud_reco_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for exceptions raised when using the CloudRecoService. 3 | """ 4 | 5 | import io 6 | import uuid 7 | from http import HTTPStatus 8 | 9 | import pytest 10 | from mock_vws import MockVWS 11 | from mock_vws.database import VuforiaDatabase 12 | from mock_vws.states import States 13 | 14 | from vws import CloudRecoService 15 | from vws.exceptions.base_exceptions import CloudRecoError 16 | from vws.exceptions.cloud_reco_exceptions import ( 17 | AuthenticationFailureError, 18 | BadImageError, 19 | InactiveProjectError, 20 | MaxNumResultsOutOfRangeError, 21 | RequestTimeTooSkewedError, 22 | ) 23 | from vws.exceptions.custom_exceptions import ( 24 | RequestEntityTooLargeError, 25 | ) 26 | 27 | 28 | def test_too_many_max_results( 29 | cloud_reco_client: CloudRecoService, 30 | high_quality_image: io.BytesIO, 31 | ) -> None: 32 | """ 33 | A ``MaxNumResultsOutOfRange`` error is raised if the given 34 | ``max_num_results`` is out of range. 35 | """ 36 | with pytest.raises(expected_exception=MaxNumResultsOutOfRangeError) as exc: 37 | cloud_reco_client.query( 38 | image=high_quality_image, 39 | max_num_results=51, 40 | ) 41 | 42 | expected_value = ( 43 | "Integer out of range (51) in form data part 'max_result'. " 44 | "Accepted range is from 1 to 50 (inclusive)." 45 | ) 46 | assert str(object=exc.value) == exc.value.response.text == expected_value 47 | 48 | 49 | def test_image_too_large( 50 | cloud_reco_client: CloudRecoService, 51 | png_too_large: io.BytesIO | io.BufferedRandom, 52 | ) -> None: 53 | """ 54 | A ``RequestEntityTooLarge`` exception is raised if an image which is too 55 | large is given. 56 | """ 57 | with pytest.raises(expected_exception=RequestEntityTooLargeError) as exc: 58 | cloud_reco_client.query(image=png_too_large) 59 | 60 | assert ( 61 | exc.value.response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE 62 | ) 63 | 64 | 65 | def test_cloudrecoexception_inheritance() -> None: 66 | """ 67 | CloudRecoService-specific exceptions inherit from CloudRecoException. 68 | """ 69 | subclasses = [ 70 | MaxNumResultsOutOfRangeError, 71 | InactiveProjectError, 72 | BadImageError, 73 | AuthenticationFailureError, 74 | RequestTimeTooSkewedError, 75 | ] 76 | for subclass in subclasses: 77 | assert issubclass(subclass, CloudRecoError) 78 | 79 | 80 | def test_authentication_failure( 81 | high_quality_image: io.BytesIO, 82 | ) -> None: 83 | """ 84 | An ``AuthenticationFailure`` exception is raised when the client access key 85 | exists but the client secret key is incorrect. 86 | """ 87 | database = VuforiaDatabase() 88 | cloud_reco_client = CloudRecoService( 89 | client_access_key=database.client_access_key, 90 | client_secret_key=uuid.uuid4().hex, 91 | ) 92 | with MockVWS() as mock: 93 | mock.add_database(database=database) 94 | 95 | with pytest.raises( 96 | expected_exception=AuthenticationFailureError 97 | ) as exc: 98 | cloud_reco_client.query(image=high_quality_image) 99 | 100 | assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED 101 | 102 | 103 | def test_inactive_project( 104 | high_quality_image: io.BytesIO, 105 | ) -> None: 106 | """ 107 | An ``InactiveProject`` exception is raised when querying an inactive 108 | database. 109 | """ 110 | database = VuforiaDatabase(state=States.PROJECT_INACTIVE) 111 | with MockVWS() as mock: 112 | mock.add_database(database=database) 113 | cloud_reco_client = CloudRecoService( 114 | client_access_key=database.client_access_key, 115 | client_secret_key=database.client_secret_key, 116 | ) 117 | 118 | with pytest.raises(expected_exception=InactiveProjectError) as exc: 119 | cloud_reco_client.query(image=high_quality_image) 120 | 121 | response = exc.value.response 122 | assert response.status_code == HTTPStatus.FORBIDDEN 123 | # We need one test which checks tell position 124 | # and so we choose this one almost at random. 125 | assert response.tell_position != 0 126 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the ``CloudRecoService`` querying functionality. 3 | """ 4 | 5 | import io 6 | import uuid 7 | from typing import BinaryIO 8 | 9 | from mock_vws import MockVWS 10 | from mock_vws.database import VuforiaDatabase 11 | 12 | from vws import VWS, CloudRecoService 13 | from vws.include_target_data import CloudRecoIncludeTargetData 14 | 15 | 16 | class TestQuery: 17 | """ 18 | Tests for making image queries. 19 | """ 20 | 21 | @staticmethod 22 | def test_no_matches( 23 | cloud_reco_client: CloudRecoService, 24 | image: io.BytesIO | BinaryIO, 25 | ) -> None: 26 | """ 27 | An empty list is returned if there are no matches. 28 | """ 29 | result = cloud_reco_client.query(image=image) 30 | assert result == [] 31 | 32 | @staticmethod 33 | def test_match( 34 | vws_client: VWS, 35 | cloud_reco_client: CloudRecoService, 36 | image: io.BytesIO | BinaryIO, 37 | ) -> None: 38 | """ 39 | Details of matching targets are returned. 40 | """ 41 | target_id = vws_client.add_target( 42 | name="x", 43 | width=1, 44 | image=image, 45 | active_flag=True, 46 | application_metadata=None, 47 | ) 48 | vws_client.wait_for_target_processed(target_id=target_id) 49 | [matching_target] = cloud_reco_client.query(image=image) 50 | assert matching_target.target_id == target_id 51 | 52 | 53 | class TestCustomBaseVWQURL: 54 | """ 55 | Tests for using a custom base VWQ URL. 56 | """ 57 | 58 | @staticmethod 59 | def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: 60 | """ 61 | It is possible to use query a target to a database under a custom VWQ 62 | URL. 63 | """ 64 | base_vwq_url = "http://example.com" 65 | with MockVWS(base_vwq_url=base_vwq_url) as mock: 66 | database = VuforiaDatabase() 67 | mock.add_database(database=database) 68 | vws_client = VWS( 69 | server_access_key=database.server_access_key, 70 | server_secret_key=database.server_secret_key, 71 | ) 72 | 73 | target_id = vws_client.add_target( 74 | name="x", 75 | width=1, 76 | image=image, 77 | active_flag=True, 78 | application_metadata=None, 79 | ) 80 | 81 | vws_client.wait_for_target_processed(target_id=target_id) 82 | 83 | cloud_reco_client = CloudRecoService( 84 | client_access_key=database.client_access_key, 85 | client_secret_key=database.client_secret_key, 86 | base_vwq_url=base_vwq_url, 87 | ) 88 | 89 | matches = cloud_reco_client.query(image=image) 90 | assert len(matches) == 1 91 | match = matches[0] 92 | assert match.target_id == target_id 93 | 94 | 95 | class TestMaxNumResults: 96 | """ 97 | Tests for the ``max_num_results`` parameter of ``query``. 98 | """ 99 | 100 | @staticmethod 101 | def test_default( 102 | vws_client: VWS, 103 | cloud_reco_client: CloudRecoService, 104 | image: io.BytesIO | BinaryIO, 105 | ) -> None: 106 | """ 107 | By default the maximum number of results is 1. 108 | """ 109 | target_id = vws_client.add_target( 110 | name=uuid.uuid4().hex, 111 | width=1, 112 | image=image, 113 | active_flag=True, 114 | application_metadata=None, 115 | ) 116 | target_id_2 = vws_client.add_target( 117 | name=uuid.uuid4().hex, 118 | width=1, 119 | image=image, 120 | active_flag=True, 121 | application_metadata=None, 122 | ) 123 | vws_client.wait_for_target_processed(target_id=target_id) 124 | vws_client.wait_for_target_processed(target_id=target_id_2) 125 | matches = cloud_reco_client.query(image=image) 126 | assert len(matches) == 1 127 | 128 | @staticmethod 129 | def test_custom( 130 | vws_client: VWS, 131 | cloud_reco_client: CloudRecoService, 132 | image: io.BytesIO | BinaryIO, 133 | ) -> None: 134 | """ 135 | It is possible to set a custom ``max_num_results``. 136 | """ 137 | target_id = vws_client.add_target( 138 | name=uuid.uuid4().hex, 139 | width=1, 140 | image=image, 141 | active_flag=True, 142 | application_metadata=None, 143 | ) 144 | target_id_2 = vws_client.add_target( 145 | name=uuid.uuid4().hex, 146 | width=1, 147 | image=image, 148 | active_flag=True, 149 | application_metadata=None, 150 | ) 151 | target_id_3 = vws_client.add_target( 152 | name=uuid.uuid4().hex, 153 | width=1, 154 | image=image, 155 | active_flag=True, 156 | application_metadata=None, 157 | ) 158 | vws_client.wait_for_target_processed(target_id=target_id) 159 | vws_client.wait_for_target_processed(target_id=target_id_2) 160 | vws_client.wait_for_target_processed(target_id=target_id_3) 161 | max_num_results = 2 162 | matches = cloud_reco_client.query( 163 | image=image, 164 | max_num_results=max_num_results, 165 | ) 166 | assert len(matches) == max_num_results 167 | 168 | 169 | class TestIncludeTargetData: 170 | """ 171 | Tests for the ``include_target_data`` parameter of ``query``. 172 | """ 173 | 174 | @staticmethod 175 | def test_default( 176 | vws_client: VWS, 177 | cloud_reco_client: CloudRecoService, 178 | image: io.BytesIO | BinaryIO, 179 | ) -> None: 180 | """ 181 | By default, target data is only returned in the top match. 182 | """ 183 | target_id = vws_client.add_target( 184 | name=uuid.uuid4().hex, 185 | width=1, 186 | image=image, 187 | active_flag=True, 188 | application_metadata=None, 189 | ) 190 | target_id_2 = vws_client.add_target( 191 | name=uuid.uuid4().hex, 192 | width=1, 193 | image=image, 194 | active_flag=True, 195 | application_metadata=None, 196 | ) 197 | vws_client.wait_for_target_processed(target_id=target_id) 198 | vws_client.wait_for_target_processed(target_id=target_id_2) 199 | top_match, second_match = cloud_reco_client.query( 200 | image=image, 201 | max_num_results=2, 202 | ) 203 | assert top_match.target_data is not None 204 | assert second_match.target_data is None 205 | 206 | @staticmethod 207 | def test_top( 208 | vws_client: VWS, 209 | cloud_reco_client: CloudRecoService, 210 | image: io.BytesIO | BinaryIO, 211 | ) -> None: 212 | """ 213 | When ``CloudRecoIncludeTargetData.TOP`` is given, target data is only 214 | returned in the top match. 215 | """ 216 | target_id = vws_client.add_target( 217 | name=uuid.uuid4().hex, 218 | width=1, 219 | image=image, 220 | active_flag=True, 221 | application_metadata=None, 222 | ) 223 | target_id_2 = vws_client.add_target( 224 | name=uuid.uuid4().hex, 225 | width=1, 226 | image=image, 227 | active_flag=True, 228 | application_metadata=None, 229 | ) 230 | vws_client.wait_for_target_processed(target_id=target_id) 231 | vws_client.wait_for_target_processed(target_id=target_id_2) 232 | top_match, second_match = cloud_reco_client.query( 233 | image=image, 234 | max_num_results=2, 235 | include_target_data=CloudRecoIncludeTargetData.TOP, 236 | ) 237 | assert top_match.target_data is not None 238 | assert second_match.target_data is None 239 | 240 | @staticmethod 241 | def test_none( 242 | vws_client: VWS, 243 | cloud_reco_client: CloudRecoService, 244 | image: io.BytesIO | BinaryIO, 245 | ) -> None: 246 | """ 247 | When ``CloudRecoIncludeTargetData.NONE`` is given, target data is not 248 | returned in any match. 249 | """ 250 | target_id = vws_client.add_target( 251 | name=uuid.uuid4().hex, 252 | width=1, 253 | image=image, 254 | active_flag=True, 255 | application_metadata=None, 256 | ) 257 | target_id_2 = vws_client.add_target( 258 | name=uuid.uuid4().hex, 259 | width=1, 260 | image=image, 261 | active_flag=True, 262 | application_metadata=None, 263 | ) 264 | vws_client.wait_for_target_processed(target_id=target_id) 265 | vws_client.wait_for_target_processed(target_id=target_id_2) 266 | top_match, second_match = cloud_reco_client.query( 267 | image=image, 268 | max_num_results=2, 269 | include_target_data=CloudRecoIncludeTargetData.NONE, 270 | ) 271 | assert top_match.target_data is None 272 | assert second_match.target_data is None 273 | 274 | @staticmethod 275 | def test_all( 276 | vws_client: VWS, 277 | cloud_reco_client: CloudRecoService, 278 | image: io.BytesIO | BinaryIO, 279 | ) -> None: 280 | """ 281 | When ``CloudRecoIncludeTargetData.ALL`` is given, target data is 282 | returned in all matches. 283 | """ 284 | target_id = vws_client.add_target( 285 | name=uuid.uuid4().hex, 286 | width=1, 287 | image=image, 288 | active_flag=True, 289 | application_metadata=None, 290 | ) 291 | target_id_2 = vws_client.add_target( 292 | name=uuid.uuid4().hex, 293 | width=1, 294 | image=image, 295 | active_flag=True, 296 | application_metadata=None, 297 | ) 298 | vws_client.wait_for_target_processed(target_id=target_id) 299 | vws_client.wait_for_target_processed(target_id=target_id_2) 300 | top_match, second_match = cloud_reco_client.query( 301 | image=image, 302 | max_num_results=2, 303 | include_target_data=CloudRecoIncludeTargetData.ALL, 304 | ) 305 | assert top_match.target_data is not None 306 | assert second_match.target_data is not None 307 | -------------------------------------------------------------------------------- /tests/test_vws.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for helper functions for managing a Vuforia database. 3 | """ 4 | 5 | import base64 6 | import datetime 7 | import io 8 | import secrets 9 | import uuid 10 | from typing import BinaryIO 11 | 12 | import pytest 13 | from freezegun import freeze_time 14 | from mock_vws import MockVWS 15 | from mock_vws.database import VuforiaDatabase 16 | 17 | from vws import VWS, CloudRecoService 18 | from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError 19 | from vws.reports import ( 20 | DatabaseSummaryReport, 21 | TargetRecord, 22 | TargetStatuses, 23 | TargetSummaryReport, 24 | ) 25 | 26 | 27 | class TestAddTarget: 28 | """ 29 | Tests for adding a target. 30 | """ 31 | 32 | @staticmethod 33 | @pytest.mark.parametrize( 34 | argnames="application_metadata", 35 | argvalues=[None, b"a"], 36 | ) 37 | @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) 38 | def test_add_target( 39 | vws_client: VWS, 40 | image: io.BytesIO | BinaryIO, 41 | application_metadata: bytes | None, 42 | cloud_reco_client: CloudRecoService, 43 | *, 44 | active_flag: bool, 45 | ) -> None: 46 | """ 47 | No exception is raised when adding one target. 48 | """ 49 | name = "x" 50 | width = 1 51 | if application_metadata is None: 52 | encoded_metadata = None 53 | else: 54 | encoded_metadata_bytes = base64.b64encode(s=application_metadata) 55 | encoded_metadata = encoded_metadata_bytes.decode(encoding="utf-8") 56 | 57 | target_id = vws_client.add_target( 58 | name=name, 59 | width=width, 60 | image=image, 61 | application_metadata=encoded_metadata, 62 | active_flag=active_flag, 63 | ) 64 | target_record = vws_client.get_target_record( 65 | target_id=target_id, 66 | ).target_record 67 | assert target_record.name == name 68 | assert target_record.width == width 69 | assert target_record.active_flag is active_flag 70 | vws_client.wait_for_target_processed(target_id=target_id) 71 | matching_targets = cloud_reco_client.query(image=image) 72 | if active_flag: 73 | [matching_target] = matching_targets 74 | assert matching_target.target_id == target_id 75 | assert matching_target.target_data is not None 76 | query_metadata = matching_target.target_data.application_metadata 77 | assert query_metadata == encoded_metadata 78 | else: 79 | assert matching_targets == [] 80 | 81 | @staticmethod 82 | def test_add_two_targets( 83 | vws_client: VWS, 84 | image: io.BytesIO | BinaryIO, 85 | ) -> None: 86 | """No exception is raised when adding two targets with different names. 87 | 88 | This demonstrates that the image seek position is not changed. 89 | """ 90 | for name in ("a", "b"): 91 | vws_client.add_target( 92 | name=name, 93 | width=1, 94 | image=image, 95 | active_flag=True, 96 | application_metadata=None, 97 | ) 98 | 99 | 100 | class TestCustomBaseVWSURL: 101 | """ 102 | Tests for using a custom base VWS URL. 103 | """ 104 | 105 | @staticmethod 106 | def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: 107 | """ 108 | It is possible to use add a target to a database under a custom VWS 109 | URL. 110 | """ 111 | base_vws_url = "http://example.com" 112 | with MockVWS(base_vws_url=base_vws_url) as mock: 113 | database = VuforiaDatabase() 114 | mock.add_database(database=database) 115 | vws_client = VWS( 116 | server_access_key=database.server_access_key, 117 | server_secret_key=database.server_secret_key, 118 | base_vws_url=base_vws_url, 119 | ) 120 | 121 | vws_client.add_target( 122 | name="x", 123 | width=1, 124 | image=image, 125 | active_flag=True, 126 | application_metadata=None, 127 | ) 128 | 129 | 130 | class TestListTargets: 131 | """ 132 | Tests for listing targets. 133 | """ 134 | 135 | @staticmethod 136 | def test_list_targets( 137 | vws_client: VWS, 138 | image: io.BytesIO | BinaryIO, 139 | ) -> None: 140 | """ 141 | It is possible to get a list of target IDs. 142 | """ 143 | id_1 = vws_client.add_target( 144 | name="x", 145 | width=1, 146 | image=image, 147 | active_flag=True, 148 | application_metadata=None, 149 | ) 150 | id_2 = vws_client.add_target( 151 | name="a", 152 | width=1, 153 | image=image, 154 | active_flag=True, 155 | application_metadata=None, 156 | ) 157 | assert sorted(vws_client.list_targets()) == sorted([id_1, id_2]) 158 | 159 | 160 | class TestDelete: 161 | """ 162 | Test for deleting a target. 163 | """ 164 | 165 | @staticmethod 166 | def test_delete_target( 167 | vws_client: VWS, 168 | image: io.BytesIO | BinaryIO, 169 | ) -> None: 170 | """ 171 | It is possible to delete a target. 172 | """ 173 | target_id = vws_client.add_target( 174 | name="x", 175 | width=1, 176 | image=image, 177 | active_flag=True, 178 | application_metadata=None, 179 | ) 180 | 181 | vws_client.wait_for_target_processed(target_id=target_id) 182 | assert target_id in vws_client.list_targets() 183 | vws_client.delete_target(target_id=target_id) 184 | assert target_id not in vws_client.list_targets() 185 | 186 | 187 | class TestGetTargetSummaryReport: 188 | """ 189 | Tests for getting a summary report for a target. 190 | """ 191 | 192 | @staticmethod 193 | def test_get_target_summary_report( 194 | vws_client: VWS, 195 | image: io.BytesIO | BinaryIO, 196 | ) -> None: 197 | """ 198 | Details of a target are returned by ``get_target_summary_report``. 199 | """ 200 | date = "2018-04-25" 201 | target_name = uuid.uuid4().hex 202 | with freeze_time(time_to_freeze=date): 203 | target_id = vws_client.add_target( 204 | name=target_name, 205 | width=1, 206 | image=image, 207 | active_flag=True, 208 | application_metadata=None, 209 | ) 210 | 211 | report = vws_client.get_target_summary_report(target_id=target_id) 212 | 213 | expected_report = TargetSummaryReport( 214 | status=TargetStatuses.SUCCESS, 215 | database_name=report.database_name, 216 | target_name=target_name, 217 | upload_date=datetime.date(year=2018, month=4, day=25), 218 | active_flag=True, 219 | tracking_rating=report.tracking_rating, 220 | total_recos=0, 221 | current_month_recos=0, 222 | previous_month_recos=0, 223 | ) 224 | 225 | assert report.status == expected_report.status 226 | assert report.database_name == expected_report.database_name 227 | assert report.target_name == expected_report.target_name 228 | assert report.upload_date == expected_report.upload_date 229 | assert report.active_flag == expected_report.active_flag 230 | assert report.tracking_rating == expected_report.tracking_rating 231 | assert report.total_recos == expected_report.total_recos 232 | assert ( 233 | report.current_month_recos == expected_report.current_month_recos 234 | ) 235 | assert ( 236 | report.previous_month_recos == expected_report.previous_month_recos 237 | ) 238 | 239 | assert report == expected_report 240 | 241 | 242 | class TestGetDatabaseSummaryReport: 243 | """ 244 | Tests for getting a summary report for a database. 245 | """ 246 | 247 | @staticmethod 248 | def test_get_target(vws_client: VWS) -> None: 249 | """ 250 | Details of a database are returned by ``get_database_summary_report``. 251 | """ 252 | report = vws_client.get_database_summary_report() 253 | 254 | expected_report = DatabaseSummaryReport( 255 | active_images=0, 256 | current_month_recos=0, 257 | failed_images=0, 258 | inactive_images=0, 259 | name=report.name, 260 | previous_month_recos=0, 261 | processing_images=0, 262 | reco_threshold=1000, 263 | request_quota=100000, 264 | request_usage=0, 265 | target_quota=1000, 266 | total_recos=0, 267 | ) 268 | 269 | assert report.active_images == expected_report.active_images 270 | assert ( 271 | report.current_month_recos == expected_report.current_month_recos 272 | ) 273 | assert report.failed_images == expected_report.failed_images 274 | assert report.inactive_images == expected_report.inactive_images 275 | assert report.name == expected_report.name 276 | assert ( 277 | report.previous_month_recos == expected_report.previous_month_recos 278 | ) 279 | assert report.processing_images == expected_report.processing_images 280 | assert report.reco_threshold == expected_report.reco_threshold 281 | assert report.request_quota == expected_report.request_quota 282 | assert report.request_usage == expected_report.request_usage 283 | assert report.target_quota == expected_report.target_quota 284 | assert report.total_recos == expected_report.total_recos 285 | 286 | assert report == expected_report 287 | 288 | 289 | class TestGetTargetRecord: 290 | """ 291 | Tests for getting a record of a target. 292 | """ 293 | 294 | @staticmethod 295 | def test_get_target_record( 296 | vws_client: VWS, 297 | image: io.BytesIO | BinaryIO, 298 | ) -> None: 299 | """ 300 | Details of a target are returned by ``get_target_record``. 301 | """ 302 | target_id = vws_client.add_target( 303 | name="x", 304 | width=1, 305 | image=image, 306 | active_flag=True, 307 | application_metadata=None, 308 | ) 309 | 310 | result = vws_client.get_target_record(target_id=target_id) 311 | expected_target_record = TargetRecord( 312 | target_id=target_id, 313 | active_flag=True, 314 | name="x", 315 | width=1, 316 | tracking_rating=-1, 317 | reco_rating="", 318 | ) 319 | 320 | assert result.target_record == expected_target_record 321 | 322 | assert ( 323 | result.target_record.target_id == expected_target_record.target_id 324 | ) 325 | assert ( 326 | result.target_record.active_flag 327 | == expected_target_record.active_flag 328 | ) 329 | assert result.target_record.name == expected_target_record.name 330 | assert result.target_record.width == expected_target_record.width 331 | assert ( 332 | result.target_record.tracking_rating 333 | == expected_target_record.tracking_rating 334 | ) 335 | assert ( 336 | result.target_record.reco_rating 337 | == expected_target_record.reco_rating 338 | ) 339 | 340 | assert result.status == TargetStatuses.PROCESSING 341 | 342 | @staticmethod 343 | def test_get_failed( 344 | vws_client: VWS, 345 | image_file_failed_state: io.BytesIO, 346 | ) -> None: 347 | """ 348 | Check that the report works with a failed target. 349 | """ 350 | target_id = vws_client.add_target( 351 | name="x", 352 | width=1, 353 | image=image_file_failed_state, 354 | active_flag=True, 355 | application_metadata=None, 356 | ) 357 | 358 | vws_client.wait_for_target_processed(target_id=target_id) 359 | result = vws_client.get_target_record(target_id=target_id) 360 | 361 | assert result.status == TargetStatuses.FAILED 362 | 363 | 364 | class TestWaitForTargetProcessed: 365 | """ 366 | Tests for waiting for a target to be processed. 367 | """ 368 | 369 | @staticmethod 370 | def test_wait_for_target_processed( 371 | vws_client: VWS, 372 | image: io.BytesIO | BinaryIO, 373 | ) -> None: 374 | """ 375 | It is possible to wait until a target is processed. 376 | """ 377 | target_id = vws_client.add_target( 378 | name="x", 379 | width=1, 380 | image=image, 381 | active_flag=True, 382 | application_metadata=None, 383 | ) 384 | report = vws_client.get_target_summary_report(target_id=target_id) 385 | assert report.status == TargetStatuses.PROCESSING 386 | vws_client.wait_for_target_processed(target_id=target_id) 387 | report = vws_client.get_target_summary_report(target_id=target_id) 388 | assert report.status != TargetStatuses.PROCESSING 389 | 390 | @staticmethod 391 | def test_default_seconds_between_requests( 392 | image: io.BytesIO | BinaryIO, 393 | ) -> None: 394 | """ 395 | By default, 0.2 seconds are waited between polling requests. 396 | """ 397 | with MockVWS(processing_time_seconds=0.5) as mock: 398 | database = VuforiaDatabase() 399 | mock.add_database(database=database) 400 | vws_client = VWS( 401 | server_access_key=database.server_access_key, 402 | server_secret_key=database.server_secret_key, 403 | ) 404 | 405 | target_id = vws_client.add_target( 406 | name="x", 407 | width=1, 408 | image=image, 409 | active_flag=True, 410 | application_metadata=None, 411 | ) 412 | 413 | vws_client.wait_for_target_processed(target_id=target_id) 414 | report = vws_client.get_database_summary_report() 415 | expected_requests = ( 416 | # Add target request 417 | 1 418 | + 419 | # Database summary request 420 | 1 421 | + 422 | # Initial request 423 | 1 424 | + 425 | # Request after 0.2 seconds - not processed 426 | 1 427 | + 428 | # Request after 0.4 seconds - not processed 429 | # This assumes that there is less than 0.1 seconds taken 430 | # between the start of the target processing and the start of 431 | # waiting for the target to be processed. 432 | 1 433 | + 434 | # Request after 0.6 seconds - processed 435 | 1 436 | ) 437 | # At the time of writing there is a bug which prevents request 438 | # usage from being tracked so we cannot track this. 439 | expected_requests = 0 440 | assert report.request_usage == expected_requests 441 | 442 | @staticmethod 443 | def test_custom_seconds_between_requests( 444 | image: io.BytesIO | BinaryIO, 445 | ) -> None: 446 | """ 447 | It is possible to customize the time waited between polling requests. 448 | """ 449 | with MockVWS(processing_time_seconds=0.5) as mock: 450 | database = VuforiaDatabase() 451 | mock.add_database(database=database) 452 | vws_client = VWS( 453 | server_access_key=database.server_access_key, 454 | server_secret_key=database.server_secret_key, 455 | ) 456 | 457 | target_id = vws_client.add_target( 458 | name="x", 459 | width=1, 460 | image=image, 461 | active_flag=True, 462 | application_metadata=None, 463 | ) 464 | 465 | vws_client.wait_for_target_processed( 466 | target_id=target_id, 467 | seconds_between_requests=0.3, 468 | ) 469 | report = vws_client.get_database_summary_report() 470 | expected_requests = ( 471 | # Add target request 472 | 1 473 | + 474 | # Database summary request 475 | 1 476 | + 477 | # Initial request 478 | 1 479 | + 480 | # Request after 0.3 seconds - not processed 481 | # This assumes that there is less than 0.2 seconds taken 482 | # between the start of the target processing and the start of 483 | # waiting for the target to be processed. 484 | 1 485 | + 486 | # Request after 0.6 seconds - processed 487 | 1 488 | ) 489 | # At the time of writing there is a bug which prevents request 490 | # usage from being tracked so we cannot track this. 491 | expected_requests = 0 492 | assert report.request_usage == expected_requests 493 | 494 | @staticmethod 495 | def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None: 496 | """ 497 | It is possible to set a maximum timeout. 498 | """ 499 | with MockVWS(processing_time_seconds=0.5) as mock: 500 | database = VuforiaDatabase() 501 | mock.add_database(database=database) 502 | vws_client = VWS( 503 | server_access_key=database.server_access_key, 504 | server_secret_key=database.server_secret_key, 505 | ) 506 | 507 | target_id = vws_client.add_target( 508 | name="x", 509 | width=1, 510 | image=image, 511 | active_flag=True, 512 | application_metadata=None, 513 | ) 514 | 515 | report = vws_client.get_target_summary_report(target_id=target_id) 516 | assert report.status == TargetStatuses.PROCESSING 517 | with pytest.raises( 518 | expected_exception=TargetProcessingTimeoutError 519 | ): 520 | vws_client.wait_for_target_processed( 521 | target_id=target_id, 522 | timeout_seconds=0.1, 523 | ) 524 | 525 | vws_client.wait_for_target_processed( 526 | target_id=target_id, 527 | timeout_seconds=0.5, 528 | ) 529 | report = vws_client.get_target_summary_report(target_id=target_id) 530 | assert report.status != TargetStatuses.PROCESSING 531 | 532 | 533 | class TestGetDuplicateTargets: 534 | """ 535 | Tests for getting duplicate targets. 536 | """ 537 | 538 | @staticmethod 539 | def test_get_duplicate_targets( 540 | vws_client: VWS, 541 | image: io.BytesIO | BinaryIO, 542 | ) -> None: 543 | """ 544 | It is possible to get the IDs of similar targets. 545 | """ 546 | target_id = vws_client.add_target( 547 | name="x", 548 | width=1, 549 | image=image, 550 | active_flag=True, 551 | application_metadata=None, 552 | ) 553 | similar_target_id = vws_client.add_target( 554 | name="a", 555 | width=1, 556 | image=image, 557 | active_flag=True, 558 | application_metadata=None, 559 | ) 560 | 561 | vws_client.wait_for_target_processed(target_id=target_id) 562 | vws_client.wait_for_target_processed(target_id=similar_target_id) 563 | duplicates = vws_client.get_duplicate_targets(target_id=target_id) 564 | assert duplicates == [similar_target_id] 565 | 566 | 567 | class TestUpdateTarget: 568 | """ 569 | Tests for updating a target. 570 | """ 571 | 572 | @staticmethod 573 | def test_update_target( 574 | vws_client: VWS, 575 | image: io.BytesIO | BinaryIO, 576 | different_high_quality_image: io.BytesIO, 577 | cloud_reco_client: CloudRecoService, 578 | ) -> None: 579 | """ 580 | It is possible to update a target. 581 | """ 582 | old_name = uuid.uuid4().hex 583 | old_width = secrets.choice(seq=range(1, 5000)) / 100 584 | target_id = vws_client.add_target( 585 | name=old_name, 586 | width=old_width, 587 | image=image, 588 | active_flag=True, 589 | application_metadata=None, 590 | ) 591 | vws_client.wait_for_target_processed(target_id=target_id) 592 | [matching_target] = cloud_reco_client.query(image=image) 593 | assert matching_target.target_id == target_id 594 | query_target_data = matching_target.target_data 595 | assert query_target_data is not None 596 | query_metadata = query_target_data.application_metadata 597 | assert query_metadata is None 598 | 599 | new_name = uuid.uuid4().hex 600 | new_width = secrets.choice(seq=range(1, 5000)) / 100 601 | new_application_metadata = base64.b64encode(s=b"a").decode( 602 | encoding="ascii", 603 | ) 604 | vws_client.update_target( 605 | target_id=target_id, 606 | name=new_name, 607 | width=new_width, 608 | active_flag=True, 609 | image=different_high_quality_image, 610 | application_metadata=new_application_metadata, 611 | ) 612 | 613 | vws_client.wait_for_target_processed(target_id=target_id) 614 | [ 615 | matching_target, 616 | ] = cloud_reco_client.query(image=different_high_quality_image) 617 | assert matching_target.target_id == target_id 618 | query_target_data = matching_target.target_data 619 | assert query_target_data is not None 620 | query_metadata = query_target_data.application_metadata 621 | assert query_metadata == new_application_metadata 622 | 623 | vws_client.update_target( 624 | target_id=target_id, 625 | active_flag=False, 626 | ) 627 | 628 | target_details = vws_client.get_target_record(target_id=target_id) 629 | assert target_details.target_record.name == new_name 630 | assert target_details.target_record.width == new_width 631 | assert not target_details.target_record.active_flag 632 | 633 | @staticmethod 634 | def test_no_fields_given( 635 | vws_client: VWS, 636 | image: io.BytesIO | BinaryIO, 637 | ) -> None: 638 | """ 639 | It is possible to give no update fields. 640 | """ 641 | target_id = vws_client.add_target( 642 | name="x", 643 | width=1, 644 | image=image, 645 | active_flag=True, 646 | application_metadata=None, 647 | ) 648 | vws_client.wait_for_target_processed(target_id=target_id) 649 | vws_client.update_target(target_id=target_id) 650 | -------------------------------------------------------------------------------- /tests/test_vws_exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for VWS exceptions. 3 | """ 4 | 5 | import io 6 | import uuid 7 | from http import HTTPStatus 8 | 9 | import pytest 10 | from freezegun import freeze_time 11 | from mock_vws import MockVWS 12 | from mock_vws.database import VuforiaDatabase 13 | from mock_vws.states import States 14 | 15 | from vws import VWS 16 | from vws.exceptions.base_exceptions import VWSError 17 | from vws.exceptions.custom_exceptions import ( 18 | ServerError, 19 | ) 20 | from vws.exceptions.vws_exceptions import ( 21 | AuthenticationFailureError, 22 | BadImageError, 23 | DateRangeError, 24 | FailError, 25 | ImageTooLargeError, 26 | MetadataTooLargeError, 27 | ProjectHasNoAPIAccessError, 28 | ProjectInactiveError, 29 | ProjectSuspendedError, 30 | RequestQuotaReachedError, 31 | RequestTimeTooSkewedError, 32 | TargetNameExistError, 33 | TargetQuotaReachedError, 34 | TargetStatusNotSuccessError, 35 | TargetStatusProcessingError, 36 | UnknownTargetError, 37 | ) 38 | 39 | 40 | def test_image_too_large( 41 | vws_client: VWS, 42 | png_too_large: io.BytesIO | io.BufferedRandom, 43 | ) -> None: 44 | """ 45 | When giving an image which is too large, an ``ImageTooLarge`` exception is 46 | raised. 47 | """ 48 | with pytest.raises(expected_exception=ImageTooLargeError) as exc: 49 | vws_client.add_target( 50 | name="x", 51 | width=1, 52 | image=png_too_large, 53 | active_flag=True, 54 | application_metadata=None, 55 | ) 56 | 57 | assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 58 | 59 | 60 | def test_invalid_given_id(vws_client: VWS) -> None: 61 | """ 62 | Giving an invalid ID to a helper which requires a target ID to be given 63 | causes an ``UnknownTarget`` exception to be raised. 64 | """ 65 | target_id = "12345abc" 66 | with pytest.raises(expected_exception=UnknownTargetError) as exc: 67 | vws_client.delete_target(target_id=target_id) 68 | assert exc.value.response.status_code == HTTPStatus.NOT_FOUND 69 | assert exc.value.target_id == target_id 70 | 71 | 72 | def test_add_bad_name(vws_client: VWS, high_quality_image: io.BytesIO) -> None: 73 | """ 74 | When a name with a bad character is given, a ``ServerError`` exception is 75 | raised. 76 | """ 77 | max_char_value = 65535 78 | bad_name = chr(max_char_value + 1) 79 | with pytest.raises( 80 | expected_exception=ServerError, 81 | ) as exc: 82 | vws_client.add_target( 83 | name=bad_name, 84 | width=1, 85 | image=high_quality_image, 86 | active_flag=True, 87 | application_metadata=None, 88 | ) 89 | 90 | assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR 91 | 92 | 93 | def test_request_quota_reached() -> None: 94 | """ 95 | See https://github.com/VWS-Python/vws-python/issues/822 for writing 96 | this test. 97 | """ 98 | 99 | 100 | def test_fail(high_quality_image: io.BytesIO) -> None: 101 | """ 102 | A ``Fail`` exception is raised when the server access key does not exist. 103 | """ 104 | with MockVWS(): 105 | vws_client = VWS( 106 | server_access_key=uuid.uuid4().hex, 107 | server_secret_key=uuid.uuid4().hex, 108 | ) 109 | 110 | with pytest.raises(expected_exception=FailError) as exc: 111 | vws_client.add_target( 112 | name="x", 113 | width=1, 114 | image=high_quality_image, 115 | active_flag=True, 116 | application_metadata=None, 117 | ) 118 | 119 | assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST 120 | 121 | 122 | def test_bad_image(vws_client: VWS) -> None: 123 | """ 124 | A ``BadImage`` exception is raised when a non-image is given. 125 | """ 126 | not_an_image = io.BytesIO(initial_bytes=b"Not an image") 127 | with pytest.raises(expected_exception=BadImageError) as exc: 128 | vws_client.add_target( 129 | name="x", 130 | width=1, 131 | image=not_an_image, 132 | active_flag=True, 133 | application_metadata=None, 134 | ) 135 | 136 | assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 137 | 138 | 139 | def test_target_name_exist( 140 | vws_client: VWS, 141 | high_quality_image: io.BytesIO, 142 | ) -> None: 143 | """ 144 | A ``TargetNameExist`` exception is raised after adding two targets with the 145 | same name. 146 | """ 147 | vws_client.add_target( 148 | name="x", 149 | width=1, 150 | image=high_quality_image, 151 | active_flag=True, 152 | application_metadata=None, 153 | ) 154 | with pytest.raises(expected_exception=TargetNameExistError) as exc: 155 | vws_client.add_target( 156 | name="x", 157 | width=1, 158 | image=high_quality_image, 159 | active_flag=True, 160 | application_metadata=None, 161 | ) 162 | 163 | assert exc.value.response.status_code == HTTPStatus.FORBIDDEN 164 | assert exc.value.target_name == "x" 165 | 166 | 167 | def test_project_inactive( 168 | high_quality_image: io.BytesIO, 169 | ) -> None: 170 | """ 171 | A ``ProjectInactive`` exception is raised if adding a target to an inactive 172 | database. 173 | """ 174 | database = VuforiaDatabase(state=States.PROJECT_INACTIVE) 175 | with MockVWS() as mock: 176 | mock.add_database(database=database) 177 | vws_client = VWS( 178 | server_access_key=database.server_access_key, 179 | server_secret_key=database.server_secret_key, 180 | ) 181 | 182 | with pytest.raises(expected_exception=ProjectInactiveError) as exc: 183 | vws_client.add_target( 184 | name="x", 185 | width=1, 186 | image=high_quality_image, 187 | active_flag=True, 188 | application_metadata=None, 189 | ) 190 | 191 | assert exc.value.response.status_code == HTTPStatus.FORBIDDEN 192 | 193 | 194 | def test_target_status_processing( 195 | vws_client: VWS, 196 | high_quality_image: io.BytesIO, 197 | ) -> None: 198 | """ 199 | A ``TargetStatusProcessing`` exception is raised if trying to delete a 200 | target which is processing. 201 | """ 202 | target_id = vws_client.add_target( 203 | name="x", 204 | width=1, 205 | image=high_quality_image, 206 | active_flag=True, 207 | application_metadata=None, 208 | ) 209 | 210 | with pytest.raises(expected_exception=TargetStatusProcessingError) as exc: 211 | vws_client.delete_target(target_id=target_id) 212 | 213 | assert exc.value.response.status_code == HTTPStatus.FORBIDDEN 214 | assert exc.value.target_id == target_id 215 | 216 | 217 | def test_metadata_too_large( 218 | vws_client: VWS, 219 | high_quality_image: io.BytesIO, 220 | ) -> None: 221 | """ 222 | A ``MetadataTooLarge`` exception is raised if the metadata given is too 223 | large. 224 | """ 225 | with pytest.raises(expected_exception=MetadataTooLargeError) as exc: 226 | vws_client.add_target( 227 | name="x", 228 | width=1, 229 | image=high_quality_image, 230 | active_flag=True, 231 | application_metadata="a" * 1024 * 1024 * 10, 232 | ) 233 | 234 | assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 235 | 236 | 237 | def test_request_time_too_skewed( 238 | vws_client: VWS, 239 | high_quality_image: io.BytesIO, 240 | ) -> None: 241 | """ 242 | A ``RequestTimeTooSkewed`` exception is raised when the request time is 243 | more than five minutes different from the server time. 244 | """ 245 | target_id = vws_client.add_target( 246 | name="x", 247 | width=1, 248 | image=high_quality_image, 249 | active_flag=True, 250 | application_metadata=None, 251 | ) 252 | 253 | vws_max_time_skew = 60 * 5 254 | leeway = 10 255 | time_difference_from_now = vws_max_time_skew + leeway 256 | 257 | # We use a custom tick because we expect the following: 258 | # 259 | # * At least one time check when creating the request 260 | # * At least one time check when processing the request 261 | # 262 | # >= 1 ticks are acceptable. 263 | with ( 264 | freeze_time(auto_tick_seconds=time_difference_from_now), 265 | pytest.raises(expected_exception=RequestTimeTooSkewedError) as exc, 266 | ): 267 | vws_client.get_target_record(target_id=target_id) 268 | 269 | assert exc.value.response.status_code == HTTPStatus.FORBIDDEN 270 | 271 | 272 | def test_authentication_failure( 273 | high_quality_image: io.BytesIO, 274 | ) -> None: 275 | """ 276 | An ``AuthenticationFailure`` exception is raised when the server access key 277 | exists but the server secret key is incorrect, or when a client key is 278 | incorrect. 279 | """ 280 | database = VuforiaDatabase() 281 | 282 | vws_client = VWS( 283 | server_access_key=database.server_access_key, 284 | server_secret_key=uuid.uuid4().hex, 285 | ) 286 | 287 | with MockVWS() as mock: 288 | mock.add_database(database=database) 289 | 290 | with pytest.raises( 291 | expected_exception=AuthenticationFailureError 292 | ) as exc: 293 | vws_client.add_target( 294 | name="x", 295 | width=1, 296 | image=high_quality_image, 297 | active_flag=True, 298 | application_metadata=None, 299 | ) 300 | 301 | assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED 302 | 303 | 304 | def test_target_status_not_success( 305 | vws_client: VWS, 306 | high_quality_image: io.BytesIO, 307 | ) -> None: 308 | """ 309 | A ``TargetStatusNotSuccess`` exception is raised when updating a target 310 | which has a status which is not "Success". 311 | """ 312 | target_id = vws_client.add_target( 313 | name="x", 314 | width=1, 315 | image=high_quality_image, 316 | active_flag=True, 317 | application_metadata=None, 318 | ) 319 | 320 | with pytest.raises(expected_exception=TargetStatusNotSuccessError) as exc: 321 | vws_client.update_target(target_id=target_id) 322 | 323 | assert exc.value.response.status_code == HTTPStatus.FORBIDDEN 324 | assert exc.value.target_id == target_id 325 | 326 | 327 | def test_vwsexception_inheritance() -> None: 328 | """ 329 | VWS-related exceptions should inherit from VWSException. 330 | """ 331 | subclasses = [ 332 | AuthenticationFailureError, 333 | BadImageError, 334 | DateRangeError, 335 | FailError, 336 | ImageTooLargeError, 337 | MetadataTooLargeError, 338 | ProjectInactiveError, 339 | ProjectHasNoAPIAccessError, 340 | ProjectSuspendedError, 341 | RequestQuotaReachedError, 342 | RequestTimeTooSkewedError, 343 | TargetNameExistError, 344 | TargetQuotaReachedError, 345 | TargetStatusNotSuccessError, 346 | TargetStatusProcessingError, 347 | UnknownTargetError, 348 | ] 349 | for subclass in subclasses: 350 | assert issubclass(subclass, VWSError) 351 | 352 | 353 | def test_base_exception( 354 | vws_client: VWS, 355 | high_quality_image: io.BytesIO, 356 | ) -> None: 357 | """ 358 | ``VWSException``s has a response property. 359 | """ 360 | with pytest.raises(expected_exception=VWSError) as exc: 361 | vws_client.get_target_record(target_id="a") 362 | 363 | assert exc.value.response.status_code == HTTPStatus.NOT_FOUND 364 | 365 | vws_client.add_target( 366 | name="x", 367 | width=1, 368 | image=high_quality_image, 369 | active_flag=True, 370 | application_metadata=None, 371 | ) 372 | --------------------------------------------------------------------------------