├── src └── vws │ ├── py.typed │ ├── exceptions │ ├── __init__.py │ ├── cloud_reco_exceptions.py │ ├── base_exceptions.py │ ├── custom_exceptions.py │ └── vws_exceptions.py │ ├── __init__.py │ ├── response.py │ ├── include_target_data.py │ ├── reports.py │ ├── query.py │ └── vws.py ├── .git_archival.txt ├── docs └── source │ ├── __init__.py │ ├── changelog.rst │ ├── api-reference.rst │ ├── release-process.rst │ ├── exceptions.rst │ ├── contributing.rst │ ├── conf.py │ └── index.rst ├── tests ├── __init__.py ├── conftest.py ├── test_cloud_reco_exceptions.py ├── test_query.py ├── test_vws_exceptions.py └── test_vws.py ├── .gitattributes ├── .vscode ├── extensions.json └── settings.json ├── .prettierrc ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-merge.yml │ ├── publish-site.yml │ ├── ci.yml │ ├── lint.yml │ └── release.yml ├── LICENSE ├── spelling_private_dict.txt ├── .gitignore ├── README.rst ├── conftest.py ├── CODE_OF_CONDUCT.rst ├── CHANGELOG.rst ├── pyproject.toml └── .pre-commit-config.yaml /src/vws/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | ref-names: HEAD -> main 2 | -------------------------------------------------------------------------------- /docs/source/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Documentation. 3 | """ 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for ``vws``. 3 | """ 4 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /src/vws/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom exceptions raised by this package. 3 | """ 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "ms-python.python" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": ["*.yaml", "*.yml"], 5 | "options": { 6 | "singleQuote": true 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Adam Dangoor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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@v6 27 | 28 | - name: Install uv 29 | uses: astral-sh/setup-uv@v7 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/ . 39 | 40 | completion-ci: 41 | needs: build 42 | runs-on: ubuntu-latest 43 | if: always() # Run even if one matrix job fails 44 | steps: 45 | - name: Check matrix job status 46 | run: |- 47 | if ! ${{ needs.build.result == 'success' }}; then 48 | echo "One or more matrix jobs failed" 49 | exit 1 50 | fi 51 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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@v6 27 | 28 | - name: Install uv 29 | uses: astral-sh/setup-uv@v7 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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | $ uv run --extra=dev sphinx-build -M html docs/source docs/build -W 68 | $ python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))' 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 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |Build Status| |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 | .. |PyPI| image:: https://badge.fury.io/py/VWS-Python.svg 75 | :target: https://badge.fury.io/py/VWS-Python 76 | .. |minimum-python-version| replace:: 3.13 77 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | "source_repository": "https://github.com/VWS-Python/vws-python/", 64 | "source_branch": "main", 65 | "source_directory": "docs/source/", 66 | } 67 | 68 | # Output file base name for HTML help builder. 69 | htmlhelp_basename = "VWSPYTHONdoc" 70 | intersphinx_mapping = { 71 | "python": (f"https://docs.python.org/{minimum_python_version}", None), 72 | } 73 | nitpicky = True 74 | nitpick_ignore = (("py:class", "_io.BytesIO"),) 75 | warning_is_error = True 76 | 77 | autoclass_content = "both" 78 | 79 | # Retry link checking to avoid transient network errors. 80 | linkcheck_retries = 5 81 | 82 | spelling_word_list_filename = "../../spelling_private_dict.txt" 83 | 84 | autodoc_member_order = "bysource" 85 | 86 | rst_prolog = f""" 87 | .. |project| replace:: {project} 88 | .. |minimum-python-version| replace:: {minimum_python_version} 89 | .. |github-owner| replace:: VWS-Python 90 | .. |github-repository| replace:: vws-python 91 | """ 92 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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@v6 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@v7 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 | id: update_changelog 59 | uses: jacobtomlinson/gha-find-replace@v3 60 | with: 61 | find: "Next\n----" 62 | replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ 63 | \ }}" 64 | include: CHANGELOG.rst 65 | regex: false 66 | 67 | - name: Check Update changelog was modified 68 | run: | 69 | if [ "${{ steps.update_changelog.outputs.modifiedFiles }}" = "0" ]; then 70 | echo "Error: No files were modified when updating changelog" 71 | exit 1 72 | fi 73 | - uses: stefanzweifel/git-auto-commit-action@v7 74 | id: commit 75 | with: 76 | commit_message: Bump CHANGELOG 77 | file_pattern: CHANGELOG.rst 78 | # Error if there are no changes. 79 | skip_dirty_check: true 80 | 81 | - name: Bump version and push tag 82 | id: tag_version 83 | uses: mathieudutour/github-tag-action@v6.2 84 | with: 85 | github_token: ${{ secrets.GITHUB_TOKEN }} 86 | custom_tag: ${{ steps.calver.outputs.release }} 87 | tag_prefix: '' 88 | commit_sha: ${{ steps.commit.outputs.commit_hash }} 89 | 90 | - name: Create a GitHub release 91 | uses: ncipollo/release-action@v1 92 | with: 93 | tag: ${{ steps.tag_version.outputs.new_tag }} 94 | makeLatest: true 95 | name: Release ${{ steps.tag_version.outputs.new_tag }} 96 | body: ${{ steps.tag_version.outputs.changelog }} 97 | 98 | - name: Build a binary wheel and a source tarball 99 | run: | 100 | git fetch --tags 101 | git checkout ${{ steps.tag_version.outputs.new_tag }} 102 | uv build --sdist --wheel --out-dir dist/ 103 | uv run --extra=release check-wheel-contents dist/*.whl 104 | 105 | - name: Publish distribution 📦 to PyPI 106 | # We use PyPI trusted publishing rather than a PyPI API token. 107 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. 108 | uses: pypa/gh-action-pypi-publish@release/v1 109 | with: 110 | verbose: true 111 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 = "MIT" 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 | "Operating System :: Microsoft :: Windows", 26 | "Operating System :: POSIX", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.13", 29 | ] 30 | dynamic = [ 31 | "version", 32 | ] 33 | dependencies = [ 34 | "beartype>=0.22.9", 35 | "requests>=2.32.3", 36 | "urllib3>=2.2.3", 37 | "vws-auth-tools>=2024.7.12", 38 | ] 39 | optional-dependencies.dev = [ 40 | "actionlint-py==1.7.9.24", 41 | "check-manifest==0.51", 42 | "deptry==0.24.0", 43 | "doc8==2.0.0", 44 | "doccmd==2025.12.13", 45 | "docformatter==1.7.7", 46 | "freezegun==1.5.5", 47 | "furo==2025.9.25", 48 | "interrogate==1.7.0", 49 | "mypy[faster-cache]==1.19.1", 50 | "mypy-strict-kwargs==2025.4.3", 51 | "pre-commit==4.5.0", 52 | "pydocstyle==6.3", 53 | "pygments==2.19.2", 54 | "pylint[spelling]==4.0.4", 55 | "pylint-per-file-ignores==3.2.0", 56 | "pyproject-fmt==2.11.1", 57 | "pyright==1.1.407", 58 | "pyroma==5.0.1", 59 | "pytest==9.0.2", 60 | "pytest-cov==7.0.0", 61 | "pyyaml==6.0.3", 62 | "ruff==0.14.9", 63 | # We add shellcheck-py not only for shell scripts and shell code blocks, 64 | # but also because having it installed means that ``actionlint-py`` will 65 | # use it to lint shell commands in GitHub workflow files. 66 | "shellcheck-py==0.11.0.1", 67 | "shfmt-py==3.12.0.2", 68 | "sphinx==8.2.3", 69 | "sphinx-copybutton==0.5.2", 70 | "sphinx-lint==1.0.2", 71 | "sphinx-pyproject==0.3.0", 72 | "sphinx-substitution-extensions==2025.12.15", 73 | "sphinxcontrib-spelling==8.0.2", 74 | "sybil==9.3.0", 75 | "ty==0.0.1a35", 76 | "types-requests==2.32.4.20250913", 77 | "vulture==2.14", 78 | "vws-python-mock==2025.3.10.1", 79 | "vws-test-fixtures==2023.3.5", 80 | "yamlfix==1.19.0", 81 | ] 82 | optional-dependencies.release = [ "check-wheel-contents==0.6.3" ] 83 | urls.Documentation = "https://vws-python.github.io/vws-python/" 84 | urls.Source = "https://github.com/VWS-Python/vws-python" 85 | 86 | [tool.setuptools] 87 | zip-safe = false 88 | 89 | [tool.setuptools.packages.find] 90 | where = [ 91 | "src", 92 | ] 93 | 94 | [tool.setuptools.package-data] 95 | vws = [ 96 | "py.typed", 97 | ] 98 | 99 | [tool.distutils.bdist_wheel] 100 | universal = true 101 | 102 | [tool.setuptools_scm] 103 | 104 | # This keeps the start of the version the same as the last release. 105 | # This is useful for our documentation to include e.g. binary links 106 | # to the latest released binary. 107 | # 108 | # Code to match this is in ``conf.py``. 109 | version_scheme = "post-release" 110 | 111 | [tool.ruff] 112 | line-length = 79 113 | 114 | lint.select = [ 115 | "ALL", 116 | ] 117 | lint.ignore = [ 118 | # Ruff warns that this conflicts with the formatter. 119 | "COM812", 120 | # Allow our chosen docstring line-style - no one-line summary. 121 | "D200", 122 | "D205", 123 | "D212", 124 | # Ruff warns that this conflicts with the formatter. 125 | "ISC001", 126 | # Ignore "too-many-*" errors as they seem to get in the way more than 127 | # helping. 128 | "PLR0913", 129 | ] 130 | 131 | lint.per-file-ignores."doccmd_*.py" = [ 132 | # Allow asserts in docs. 133 | "S101", 134 | ] 135 | 136 | lint.per-file-ignores."docs/source/*.py" = [ 137 | # Allow asserts in docs. 138 | "S101", 139 | ] 140 | 141 | lint.per-file-ignores."tests/*.py" = [ 142 | # Allow asserts in tests. 143 | "S101", 144 | ] 145 | 146 | # Do not automatically remove commented out code. 147 | # We comment out code during development, and with VSCode auto-save, this code 148 | # is sometimes annoyingly removed. 149 | lint.unfixable = [ 150 | "ERA001", 151 | ] 152 | lint.pydocstyle.convention = "google" 153 | 154 | [tool.pylint] 155 | 156 | [tool.pylint.'MASTER'] 157 | 158 | # Pickle collected data for later comparisons. 159 | persistent = true 160 | 161 | # Use multiple processes to speed up Pylint. 162 | jobs = 0 163 | 164 | # List of plugins (as comma separated values of python modules names) to load, 165 | # usually to register additional checkers. 166 | # See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. 167 | # and we also add `pylint_per_file_ignores` to allow per-file ignores. 168 | # We do not use the plugins: 169 | # - pylint.extensions.code_style 170 | # - pylint.extensions.magic_value 171 | # - pylint.extensions.while_used 172 | # as they seemed to get in the way. 173 | load-plugins = [ 174 | "pylint_per_file_ignores", 175 | 'pylint.extensions.bad_builtin', 176 | 'pylint.extensions.comparison_placement', 177 | 'pylint.extensions.consider_refactoring_into_while_condition', 178 | 'pylint.extensions.docparams', 179 | 'pylint.extensions.dunder', 180 | 'pylint.extensions.eq_without_hash', 181 | 'pylint.extensions.for_any_all', 182 | 'pylint.extensions.mccabe', 183 | 'pylint.extensions.no_self_use', 184 | 'pylint.extensions.overlapping_exceptions', 185 | 'pylint.extensions.private_import', 186 | 'pylint.extensions.redefined_loop_name', 187 | 'pylint.extensions.redefined_variable_type', 188 | 'pylint.extensions.set_membership', 189 | 'pylint.extensions.typing', 190 | ] 191 | 192 | # Allow loading of arbitrary C extensions. Extensions are imported into the 193 | # active Python interpreter and may run arbitrary code. 194 | unsafe-load-any-extension = false 195 | 196 | [tool.pylint.'MESSAGES CONTROL'] 197 | 198 | # Enable the message, report, category or checker with the given id(s). You can 199 | # either give multiple identifier separated by comma (,) or put this option 200 | # multiple time (only on the command line, not in the configuration file where 201 | # it should appear only once). See also the "--disable" option for examples. 202 | enable = [ 203 | 'bad-inline-option', 204 | 'deprecated-pragma', 205 | 'file-ignored', 206 | 'spelling', 207 | 'use-symbolic-message-instead', 208 | 'useless-suppression', 209 | ] 210 | 211 | # Disable the message, report, category or checker with the given id(s). You 212 | # can either give multiple identifiers separated by comma (,) or put this 213 | # option multiple times (only on the command line, not in the configuration 214 | # file where it should appear only once).You can also use "--disable=all" to 215 | # disable everything first and then reenable specific checks. For example, if 216 | # you want to run only the similarities checker, you can use "--disable=all 217 | # --enable=similarities". If you want to run only the classes checker, but have 218 | # no Warning level messages displayed, use"--disable=all --enable=classes 219 | # --disable=W" 220 | 221 | disable = [ 222 | 'too-few-public-methods', 223 | 'too-many-locals', 224 | 'too-many-arguments', 225 | 'too-many-instance-attributes', 226 | 'too-many-return-statements', 227 | 'too-many-lines', 228 | 'locally-disabled', 229 | # Let ruff handle long lines 230 | 'line-too-long', 231 | # Let ruff handle unused imports 232 | 'unused-import', 233 | # Let ruff deal with sorting 234 | 'ungrouped-imports', 235 | # We don't need everything to be documented because of mypy 236 | 'missing-type-doc', 237 | 'missing-return-type-doc', 238 | # Too difficult to please 239 | 'duplicate-code', 240 | # Let ruff handle imports 241 | 'wrong-import-order', 242 | # mypy does not want untyped parameters. 243 | 'useless-type-doc', 244 | ] 245 | 246 | # We ignore invalid names because: 247 | # - We want to use generated module names, which may not be valid, but are never seen. 248 | # - We want to use global variables in documentation, which may not be uppercase. 249 | # - conf.py is a Sphinx configuration file which requires lowercase global variable names. 250 | per-file-ignores = [ 251 | "docs/source/conf.py:invalid-name", 252 | "docs/source/doccmd_*.py: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 | ".prettierrc", 283 | ".yamlfmt", 284 | "*.enc", 285 | ".pre-commit-config.yaml", 286 | "CHANGELOG.rst", 287 | "CODE_OF_CONDUCT.rst", 288 | "CONTRIBUTING.rst", 289 | "LICENSE", 290 | "Makefile", 291 | "ci", 292 | "ci/**", 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 | -------------------------------------------------------------------------------- /.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 | - ty 29 | - ty-docs 30 | - pyroma 31 | - ruff-check-fix 32 | - ruff-check-fix-docs 33 | - ruff-format-fix 34 | - ruff-format-fix-docs 35 | - shellcheck 36 | - shellcheck-docs 37 | - shfmt 38 | - shfmt-docs 39 | - spelling 40 | - vulture 41 | - vulture-docs 42 | - yamlfix 43 | 44 | default_install_hook_types: [pre-commit, pre-push, commit-msg] 45 | 46 | repos: 47 | - repo: meta 48 | hooks: 49 | - id: check-useless-excludes 50 | stages: [pre-commit] 51 | - repo: https://github.com/pre-commit/pre-commit-hooks 52 | rev: v6.0.0 53 | hooks: 54 | - id: check-added-large-files 55 | stages: [pre-commit] 56 | - id: check-case-conflict 57 | stages: [pre-commit] 58 | - id: check-executables-have-shebangs 59 | stages: [pre-commit] 60 | - id: check-merge-conflict 61 | stages: [pre-commit] 62 | - id: check-shebang-scripts-are-executable 63 | stages: [pre-commit] 64 | - id: check-symlinks 65 | stages: [pre-commit] 66 | - id: check-json 67 | stages: [pre-commit] 68 | - id: check-toml 69 | stages: [pre-commit] 70 | - id: check-vcs-permalinks 71 | stages: [pre-commit] 72 | - id: check-yaml 73 | stages: [pre-commit] 74 | - id: end-of-file-fixer 75 | stages: [pre-commit] 76 | - id: file-contents-sorter 77 | files: spelling_private_dict\.txt$ 78 | stages: [pre-commit] 79 | - id: trailing-whitespace 80 | stages: [pre-commit] 81 | - repo: https://github.com/pre-commit/pygrep-hooks 82 | rev: v1.10.0 83 | hooks: 84 | - id: rst-directive-colons 85 | stages: [pre-commit] 86 | - id: rst-inline-touching-normal 87 | stages: [pre-commit] 88 | - id: text-unicode-replacement-char 89 | stages: [pre-commit] 90 | - id: rst-backticks 91 | 92 | stages: [pre-commit] 93 | - repo: local 94 | hooks: 95 | - id: actionlint 96 | name: actionlint 97 | entry: uv run --extra=dev actionlint 98 | language: python 99 | pass_filenames: false 100 | types_or: [yaml] 101 | additional_dependencies: [uv==0.9.5] 102 | stages: [pre-commit] 103 | 104 | - id: docformatter 105 | name: docformatter 106 | entry: uv run --extra=dev -m docformatter --in-place 107 | language: python 108 | types_or: [python] 109 | additional_dependencies: [uv==0.9.5] 110 | stages: [pre-commit] 111 | 112 | - id: shellcheck 113 | name: shellcheck 114 | entry: uv run --extra=dev shellcheck --shell=bash 115 | language: python 116 | types_or: [shell] 117 | additional_dependencies: [uv==0.9.5] 118 | stages: [pre-commit] 119 | 120 | - id: shellcheck-docs 121 | name: shellcheck-docs 122 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=shell 123 | --language=console --command="shellcheck --shell=bash" 124 | language: python 125 | types_or: [markdown, rst] 126 | additional_dependencies: [uv==0.9.5] 127 | stages: [pre-commit] 128 | 129 | - id: shfmt 130 | name: shfmt 131 | entry: uv run --extra=dev shfmt --write --space-redirects --indent=4 132 | language: python 133 | types_or: [shell] 134 | additional_dependencies: [uv==0.9.5] 135 | stages: [pre-commit] 136 | 137 | - id: shfmt-docs 138 | name: shfmt-docs 139 | entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt 140 | --no-pad-file --command="shfmt --write --space-redirects --indent=4" 141 | language: python 142 | types_or: [markdown, rst] 143 | additional_dependencies: [uv==0.9.5] 144 | stages: [pre-commit] 145 | 146 | - id: mypy 147 | name: mypy 148 | stages: [pre-push] 149 | entry: uv run --extra=dev -m mypy 150 | language: python 151 | types_or: [python, toml] 152 | pass_filenames: false 153 | additional_dependencies: [uv==0.9.5] 154 | 155 | - id: mypy-docs 156 | name: mypy-docs 157 | stages: [pre-push] 158 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 159 | --command="mypy" 160 | language: python 161 | types_or: [markdown, rst] 162 | additional_dependencies: [uv==0.9.5] 163 | 164 | - id: check-manifest 165 | name: check-manifest 166 | stages: [pre-push] 167 | entry: uv run --extra=dev -m check_manifest 168 | language: python 169 | pass_filenames: false 170 | additional_dependencies: [uv==0.9.5] 171 | 172 | - id: pyright 173 | name: pyright 174 | stages: [pre-push] 175 | entry: uv run --extra=dev -m pyright . 176 | language: python 177 | types_or: [python, toml] 178 | pass_filenames: false 179 | additional_dependencies: [uv==0.9.5] 180 | 181 | - id: pyright-docs 182 | name: pyright-docs 183 | stages: [pre-push] 184 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 185 | --command="pyright" 186 | language: python 187 | types_or: [markdown, rst] 188 | additional_dependencies: [uv==0.9.5] 189 | 190 | - id: vulture 191 | name: vulture 192 | entry: uv run --extra=dev -m vulture . 193 | language: python 194 | types_or: [python] 195 | pass_filenames: false 196 | additional_dependencies: [uv==0.9.5] 197 | stages: [pre-commit] 198 | 199 | - id: vulture-docs 200 | name: vulture docs 201 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 202 | --command="vulture" 203 | language: python 204 | types_or: [markdown, rst] 205 | additional_dependencies: [uv==0.9.5] 206 | stages: [pre-commit] 207 | 208 | - id: pyroma 209 | name: pyroma 210 | entry: uv run --extra=dev -m pyroma --min 10 . 211 | language: python 212 | pass_filenames: false 213 | types_or: [toml] 214 | additional_dependencies: [uv==0.9.5] 215 | stages: [pre-commit] 216 | 217 | - id: deptry 218 | name: deptry 219 | entry: uv run --extra=dev -m deptry src/ 220 | language: python 221 | pass_filenames: false 222 | additional_dependencies: [uv==0.9.5] 223 | stages: [pre-commit] 224 | 225 | - id: pylint 226 | name: pylint 227 | entry: uv run --extra=dev -m pylint *.py src/ tests/ docs/ 228 | language: python 229 | stages: [manual] 230 | pass_filenames: false 231 | additional_dependencies: [uv==0.9.5] 232 | 233 | - id: pylint-docs 234 | name: pylint-docs 235 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 236 | --command="pylint" 237 | language: python 238 | stages: [manual] 239 | types_or: [markdown, rst] 240 | additional_dependencies: [uv==0.9.5] 241 | 242 | - id: ruff-check-fix 243 | name: Ruff check fix 244 | entry: uv run --extra=dev -m ruff check --fix 245 | language: python 246 | types_or: [python] 247 | additional_dependencies: [uv==0.9.5] 248 | stages: [pre-commit] 249 | 250 | - id: ruff-check-fix-docs 251 | name: Ruff check fix docs 252 | entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix" 253 | language: python 254 | types_or: [markdown, rst] 255 | additional_dependencies: [uv==0.9.5] 256 | stages: [pre-commit] 257 | 258 | - id: ruff-format-fix 259 | name: Ruff format 260 | entry: uv run --extra=dev -m ruff format 261 | language: python 262 | types_or: [python] 263 | additional_dependencies: [uv==0.9.5] 264 | stages: [pre-commit] 265 | 266 | - id: ruff-format-fix-docs 267 | name: Ruff format docs 268 | entry: uv run --extra=dev doccmd --language=python --no-pad-file --command="ruff 269 | format" 270 | language: python 271 | types_or: [markdown, rst] 272 | additional_dependencies: [uv==0.9.5] 273 | stages: [pre-commit] 274 | 275 | - id: doc8 276 | name: doc8 277 | entry: uv run --extra=dev -m doc8 278 | language: python 279 | types_or: [rst] 280 | additional_dependencies: [uv==0.9.5] 281 | stages: [pre-commit] 282 | 283 | - id: interrogate 284 | name: interrogate 285 | entry: uv run --extra=dev -m interrogate 286 | language: python 287 | types_or: [python] 288 | additional_dependencies: [uv==0.9.5] 289 | stages: [pre-commit] 290 | 291 | - id: interrogate-docs 292 | name: interrogate docs 293 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 294 | --command="interrogate" 295 | language: python 296 | types_or: [markdown, rst] 297 | additional_dependencies: [uv==0.9.5] 298 | stages: [pre-commit] 299 | 300 | - id: pyproject-fmt-fix 301 | name: pyproject-fmt 302 | entry: uv run --extra=dev pyproject-fmt 303 | language: python 304 | types_or: [toml] 305 | files: pyproject.toml 306 | additional_dependencies: [uv==0.9.5] 307 | stages: [pre-commit] 308 | 309 | - id: linkcheck 310 | name: linkcheck 311 | entry: uv run --extra=dev sphinx-build -M linkcheck docs/source docs/build 312 | -W 313 | language: python 314 | types_or: [rst] 315 | stages: [manual] 316 | pass_filenames: false 317 | additional_dependencies: [uv==0.9.5] 318 | 319 | - id: spelling 320 | name: spelling 321 | entry: uv run --extra=dev sphinx-build -M spelling docs/source docs/build 322 | -W 323 | language: python 324 | types_or: [rst] 325 | stages: [manual] 326 | pass_filenames: false 327 | additional_dependencies: [uv==0.9.5] 328 | 329 | - id: docs 330 | name: Build Documentation 331 | entry: uv run --extra=dev sphinx-build -M html docs/source docs/build -W 332 | language: python 333 | stages: [manual] 334 | pass_filenames: false 335 | additional_dependencies: [uv==0.9.5] 336 | 337 | - id: pyright-verifytypes 338 | name: pyright-verifytypes 339 | stages: [pre-push] 340 | entry: uv run --extra=dev -m pyright --verifytypes vws 341 | language: python 342 | pass_filenames: false 343 | types_or: [python] 344 | additional_dependencies: [uv==0.9.5] 345 | 346 | - id: ty 347 | name: ty 348 | stages: [pre-push] 349 | entry: uv run --extra=dev ty check 350 | language: python 351 | types_or: [python, toml] 352 | pass_filenames: false 353 | additional_dependencies: [uv==0.9.5] 354 | 355 | - id: ty-docs 356 | name: ty-docs 357 | stages: [pre-push] 358 | entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python 359 | --command="ty check" 360 | language: python 361 | types_or: [markdown, rst] 362 | additional_dependencies: [uv==0.9.5] 363 | 364 | - id: yamlfix 365 | name: yamlfix 366 | entry: uv run --extra=dev yamlfix 367 | language: python 368 | types_or: [yaml] 369 | additional_dependencies: [uv==0.9.5] 370 | stages: [pre-commit] 371 | 372 | - id: sphinx-lint 373 | name: sphinx-lint 374 | entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long 375 | language: python 376 | types_or: [rst] 377 | additional_dependencies: [uv==0.9.5] 378 | stages: [pre-commit] 379 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------