diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c5f7d90..ad86deb1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,24 +29,17 @@ jobs: with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- - + # We do not use the cache action as uv is faster than the cache action. - name: "Install dependencies" run: | - python -m pip install --upgrade pip setuptools wheel - # We use '--ignore-installed' to avoid GitHub's cache which can cause - # issues - we have seen packages from this cache be cause trouble with - # pip-extra-reqs. - python -m pip install --ignore-installed --upgrade --editable .[dev] + curl -LsSf https://astral.sh/uv/install.sh | sh + uv pip install --system --upgrade --editable .[dev] - name: "Lint" run: | - make lint + pre-commit run --all-files --hook-stage commit --verbose + pre-commit run --all-files --hook-stage push --verbose + pre-commit run --all-files --hook-stage manual --verbose - name: "Run tests" run: | @@ -55,4 +48,10 @@ jobs: pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests . --cov-report=xml - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v3" + uses: "codecov/codecov-action@v4" + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + + - uses: pre-commit-ci/lite-action@v1.0.2 + if: always() diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml new file mode 100644 index 000000000..1c75ca282 --- /dev/null +++ b/.github/workflows/dependabot-merge.yml @@ -0,0 +1,24 @@ +--- + +name: Dependabot auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8974001cd..237858d8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,12 +9,27 @@ jobs: name: Publish a release runs-on: ubuntu-latest + # Specifying an environment is strongly recommended by PyPI. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + environment: release + + permissions: + # This is needed for PyPI publishing. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + id-token: write + # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. + contents: write + strategy: matrix: python-version: ["3.12"] steps: - uses: actions/checkout@v4 + with: + # See + # https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches + token: ${{ secrets.RELEASE_PAT }} - name: "Set up Python" uses: actions/setup-python@v5 @@ -47,7 +62,7 @@ jobs: - name: Bump version and push tag id: tag_version - uses: mathieudutour/github-tag-action@v6.1 + uses: mathieudutour/github-tag-action@v6.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} custom_tag: ${{ steps.calver.outputs.release }} @@ -65,12 +80,14 @@ jobs: run: | # Checkout the latest tag - the one we just created. git fetch --tags - git checkout $(git describe --tags $(git rev-list --tags --max-count=1)) - python -m pip install build + git checkout "$(git describe --tags "$(git rev-list --tags --max-count=1)")" + python -m pip install build check-wheel-contents python -m build --sdist --wheel --outdir dist/ . + check-wheel-contents dist/*.whl + # We use PyPI trusted publishing rather than a PyPI API token. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.PYPI_API_TOKEN }} verbose: true diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index c13708757..16bf4706a 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -29,23 +29,14 @@ jobs: with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 - with: - path: ~\AppData\Local\pip\Cache - key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- - + # We do not use the cache action as uv is faster than the cache action. - name: "Install dependencies" run: | - python -m pip install --upgrade pip setuptools wheel - # We use '--ignore-installed' to avoid GitHub's cache which can cause - # issues - we have seen packages from this cache be cause trouble with - # pip-extra-reqs. - python -m pip install --ignore-installed --upgrade --editable .[dev] + irm https://astral.sh/uv/install.ps1 | iex + uv pip install --system --upgrade --editable .[dev] - name: "Run tests" run: | # We run tests against "." and not the tests directory as we test the README # and documentation. - pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests . --cov-report=xml + python -m pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests . --cov-report=xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..81166ec44 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,184 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +ci: + # We use system Python, with required dependencies specified in pyproject.toml. + # We therefore cannot use those dependencies in pre-commit CI. + skip: + - actionlint + - mypy + - check-manifest + - pyright + - vulture + - pyroma + - deptry + - pylint + - ruff-check + - ruff-format-check + - ruff-check-fix + - ruff-format-fix + - doc8 + - interrogate + - pyproject-fmt-check + - pyproject-fmt-fix + - linkcheck + - spelling + - docs + - pyright-verifytypes + +default_install_hook_types: [pre-commit, pre-push, commit-msg] +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: check-toml + - id: check-vcs-permalinks + - id: check-yaml + - id: end-of-file-fixer + - id: file-contents-sorter + files: spelling_private_dict\.txt$ + - id: trailing-whitespace +- repo: local + hooks: + - id: actionlint + name: actionlint + entry: actionlint + language: system + pass_filenames: false + types_or: [yaml] + + - id: mypy + name: mypy + stages: [push] + entry: mypy . + language: system + types_or: [python, toml] + pass_filenames: false + + - id: check-manifest + name: check-manifest + stages: [push] + entry: check-manifest . + language: system + pass_filenames: false + + - id: pyright + name: pyright + stages: [push] + entry: pyright . + language: system + types_or: [python, toml] + pass_filenames: false + + - id: vulture + name: vulture + entry: vulture --min-confidence 100 --exclude .eggs + language: system + types_or: [python] + + - id: pyroma + name: pyroma + entry: pyroma --min 10 . + language: system + pass_filenames: false + types_or: [toml] + + - id: deptry + name: deptry + entry: deptry src/ + language: system + pass_filenames: false + + - id: pylint + name: pylint + entry: pylint *.py src/ tests/ docs/ + language: system + stages: [manual] + pass_filenames: false + + - id: ruff-check + name: Ruff check + entry: ruff check + language: system + types_or: [python] + + - id: ruff-format-check + name: Ruff format check + entry: ruff format --check + language: system + types_or: [python] + + - id: ruff-check-fix + name: Ruff check fix + entry: ruff check --fix + language: system + types_or: [python] + + - id: ruff-format-fix + name: Ruff format + entry: ruff format + language: system + types_or: [python] + + - id: doc8 + name: doc8 + entry: doc8 + language: system + types_or: [rst] + + - id: interrogate + name: interrogate + entry: interrogate + language: system + types_or: [python] + + - id: pyproject-fmt-check + name: pyproject-fmt check + entry: pyproject-fmt --check + language: system + types_or: [toml] + files: pyproject.toml + + - id: pyproject-fmt-fix + name: pyproject-fmt + entry: pyproject-fmt + language: system + types_or: [toml] + files: pyproject.toml + + - id: linkcheck + name: linkcheck + entry: make -C docs/ linkcheck SPHINXOPTS=-W + language: system + types_or: [rst] + stages: [manual] + pass_filenames: false + + - id: spelling + name: spelling + entry: make -C docs/ spelling SPHINXOPTS=-W + language: system + types_or: [rst] + stages: [manual] + pass_filenames: false + + - id: docs + name: Build Documentation + entry: make docs + language: system + stages: [manual] + pass_filenames: false + + - id: pyright-verifytypes + name: pyright-verifytypes + stages: [push] + entry: pyright --verifytypes vws + language: system + pass_filenames: false + types_or: [python] diff --git a/.vscode/settings.json b/.vscode/settings.json index a05041473..9f41e3120 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "[python]": { "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" }, "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 281270708..82663c55a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,36 @@ Changelog Next ---- +2024.09.03 +------------ + +* Make ``VWS.make_request`` a public method. + +2024.09.02 +------------ + +* Breaking change: Exception names now end with ``Error``. +* Use a timeout (30 seconds) when making requests to the VWS API. +* Type hint changes: images are now ``io.BytesIO`` instances or ``io.BufferedRandom``. + +2024.02.19 +------------ + +* Add exception response attribute to ``vws.exceptions.custom_exceptions.RequestEntityTooLarge``. + +2024.02.06 +------------ + +* Exception response attributes are now ``vws.exceptions.response.Response`` instances rather than ``requests.Response`` objects. + +2024.02.04.1 +------------ + +2024.02.04 +------------ + +* Return a new error (``vws.custom_exceptions.ServerError``) when the server returns a 5xx status code. + 2023.12.27 ------------ diff --git a/LICENSE b/LICENSE index 69f733fdf..ef26969f8 100644 --- a/LICENSE +++ b/LICENSE @@ -17,4 +17,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 15fe1e8bb..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include src/vws/py.typed -include pyproject.toml diff --git a/Makefile b/Makefile index 320a0bec8..216a25740 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,8 @@ SHELL := /bin/bash -euxo pipefail -include lint.mk - # Treat Sphinx warnings as errors SPHINXOPTS := -W -.PHONY: lint -lint: \ - check-manifest \ - doc8 \ - linkcheck \ - mypy \ - ruff \ - pip-extra-reqs \ - pip-missing-reqs \ - pyproject-fmt \ - pyright \ - pyroma \ - spelling \ - vulture \ - pylint - -.PHONY: fix-lint -fix-lint: \ - fix-pyproject-fmt \ - fix-ruff - .PHONY: docs docs: make -C docs clean html SPHINXOPTS=$(SPHINXOPTS) diff --git a/README.rst b/README.rst index b1fee9e29..cb5f4b116 100644 --- a/README.rst +++ b/README.rst @@ -20,34 +20,6 @@ language. Getting Started --------------- -.. invisible-code-block: python - - import contextlib - import pathlib - import shutil - - import vws_test_fixtures - from mock_vws import MockVWS - from mock_vws.database import VuforiaDatabase - - mock = MockVWS(real_http=False) - database = VuforiaDatabase( - server_access_key='[server-access-key]', - server_secret_key='[server-secret-key]', - client_access_key='[client-access-key]', - client_secret_key='[client-secret-key]', - ) - mock.add_database(database=database) - stack = contextlib.ExitStack() - stack.enter_context(mock) - - # We rely on implementation details of the fixtures package. - image = pathlib.Path(vws_test_fixtures.__path__[0]) / 'high_quality_image.jpg' - assert image.exists(), image.resolve() - new_image = pathlib.Path('high_quality_image.jpg') - shutil.copy(image, new_image) - - .. code-block:: python import pathlib @@ -83,11 +55,6 @@ Getting Started assert matching_targets[0].target_id == target_id -.. invisible-code-block: python - new_image = pathlib.Path('high_quality_image.jpg') - new_image.unlink() - stack.close() - Full Documentation ------------------ diff --git a/conftest.py b/conftest.py index 9d744c035..059fd0968 100644 --- a/conftest.py +++ b/conftest.py @@ -1,19 +1,71 @@ """Setup for Sybil.""" +import io +from collections.abc import Generator from doctest import ELLIPSIS +from pathlib import Path -from sybil import Sybil # pyright: ignore[reportMissingTypeStubs] -from sybil.parsers.rest import ( # pyright: ignore[reportMissingTypeStubs] +import pytest +from beartype import beartype +from mock_vws import MockVWS +from mock_vws.database import VuforiaDatabase +from sybil import Sybil +from sybil.parsers.rest import ( ClearNamespaceParser, DocTestParser, PythonCodeBlockParser, ) -pytest_collect_file = Sybil( # pyright: ignore[reportUnknownVariableType] + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """ + Apply the beartype decorator to all collected test functions. + """ + for item in items: + if isinstance(item, pytest.Function): + item.obj = beartype(obj=item.obj) + + +@pytest.fixture +def make_image_file( + high_quality_image: io.BytesIO, +) -> Generator[None, None, None]: + """ + Make an image file available in the test directory. + The path of this file matches the path in the documentation. + """ + new_image = Path("high_quality_image.jpg") + buffer = high_quality_image.getvalue() + new_image.write_bytes(data=buffer) + yield + new_image.unlink() + + +@pytest.fixture +def mock_vws() -> Generator[None, None, None]: + """ + Yield a mock VWS. + + The keys used here match the keys in the documentation. + """ + database = VuforiaDatabase( + server_access_key="[server-access-key]", + server_secret_key="[server-secret-key]", + client_access_key="[client-access-key]", + client_secret_key="[client-secret-key]", + ) + # We use a low processing time so that tests run quickly. + with MockVWS(processing_time_seconds=0.2) as mock: + mock.add_database(database=database) + yield + + +pytest_collect_file = Sybil( parsers=[ ClearNamespaceParser(), DocTestParser(optionflags=ELLIPSIS), PythonCodeBlockParser(), ], patterns=["*.rst", "*.py"], + fixtures=["make_image_file", "mock_vws"], ).pytest() diff --git a/docs/source/conf.py b/docs/source/conf.py index c3861a1bf..c18eb170d 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,11 +12,10 @@ author = "Adam Dangoor" extensions = [ + "sphinx_copybutton", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", - "sphinx_autodoc_typehints", - "sphinx-prompt", "sphinx_substitution_extensions", "sphinxcontrib.spelling", ] @@ -28,12 +27,16 @@ year = datetime.datetime.now(tz=datetime.UTC).year project_copyright = f"{year}, {author}" +# Exclude the prompt from copied code with sphinx_copybutton. +# https://sphinx-copybutton.readthedocs.io/en/latest/use.html#automatic-exclusion-of-prompts-from-the-copies. +copybutton_exclude = ".linenos, .gp" + # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # Use ``importlib.metadata.version`` as per -# https://github.com/pypa/setuptools_scm#usage-from-sphinx. +# https://setuptools-scm.readthedocs.io/en/latest/usage/#usage-from-sphinx version = importlib.metadata.version(distribution_name=project) _month, _day, _year, *_ = version.split(".") release = f"{_month}.{_day}.{_year}" @@ -59,23 +62,18 @@ "python": ("https://docs.python.org/3.12", None), } nitpicky = True -warning_is_error = True -nitpick_ignore = [ +nitpick_ignore = ( + ("py:class", "BytesIO"), + ("py:class", "BufferedRandom"), ("py:class", "_io.BytesIO"), - # Requests documentation exposes ``requests.Response``, not - # ``requests.models.response``. - ("py:class", "requests.models.Response"), -] - + ("py:class", "_io.BufferedRandom"), +) +warning_is_error = True autoclass_content = "both" # Retry link checking to avoid transient network errors. linkcheck_retries = 5 -linkcheck_ignore = [ - # Requires login. - r"https://developer.vuforia.com/targetmanager", -] spelling_word_list_filename = "../../spelling_private_dict.txt" @@ -87,5 +85,3 @@ .. |github-owner| replace:: VWS-Python .. |github-repository| replace:: vws-python """ - -always_document_param_types = True diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index ca559e843..2a61da727 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -10,46 +10,50 @@ Install contribution dependencies Install Python dependencies in a virtual environment. -.. prompt:: bash +.. code-block:: console - pip install --editable '.[dev]' + $ pip install --editable '.[dev]' Spell checking requires ``enchant``. -This can be installed on macOS, for example, with `Homebrew `__: +This can be installed on macOS, for example, with `Homebrew`_: -.. prompt:: bash +.. code-block:: console - brew install enchant + $ brew install enchant and on Ubuntu with ``apt``: -.. prompt:: bash +.. code-block:: console - apt-get install -y enchant + $ apt-get install -y enchant -Linting -------- +Install ``pre-commit`` hooks: + +.. code-block:: console -Run lint tools: + $ pre-commit install -.. prompt:: bash +Linting +------- - make lint +Run lint tools either by committing, or with: -To fix some lint errors, run the following: +.. code-block:: console -.. prompt:: bash + $ pre-commit run --all-files --hook-stage commit --verbose + $ pre-commit run --all-files --hook-stage push --verbose + $ pre-commit run --all-files --hook-stage manual --verbose - make fix-lint +.. _Homebrew: https://brew.sh Running tests ------------- Run ``pytest``: -.. prompt:: bash +.. code-block:: console - pytest + $ pytest Documentation ------------- @@ -58,10 +62,10 @@ Documentation is built on Read the Docs. Run the following commands to build and view documentation locally: -.. prompt:: bash +.. code-block:: console - make docs - make open-docs + $ make docs + $ make open-docs Continuous integration ---------------------- diff --git a/docs/source/exceptions.rst b/docs/source/exceptions.rst index f48730bfd..f23ab02b8 100644 --- a/docs/source/exceptions.rst +++ b/docs/source/exceptions.rst @@ -36,3 +36,10 @@ Custom exceptions :show-inheritance: :inherited-members: Exception :exclude-members: errno, filename, filename2, strerror + +Response +-------- + +.. automodule:: vws.exceptions.response + :undoc-members: + :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index 630355629..907096d1d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,9 +4,9 @@ Installation ------------ -.. prompt:: bash +.. code-block:: console - pip3 install vws-python + $ pip install vws-python This is tested on Python 3.8+. Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. @@ -16,33 +16,6 @@ Usage See the :doc:`api-reference` for full usage details. -.. invisible-code-block: python - - import contextlib - import pathlib - import shutil - - import vws_test_fixtures - from mock_vws import MockVWS - from mock_vws.database import VuforiaDatabase - - mock = MockVWS(real_http=False) - database = VuforiaDatabase( - server_access_key='[server-access-key]', - server_secret_key='[server-secret-key]', - client_access_key='[client-access-key]', - client_secret_key='[client-secret-key]', - ) - mock.add_database(database=database) - stack = contextlib.ExitStack() - stack.enter_context(mock) - - # We rely on implementation details of the fixtures package. - image = pathlib.Path(vws_test_fixtures.__path__[0]) / 'high_quality_image.jpg' - assert image.exists(), image.resolve() - new_image = pathlib.Path('high_quality_image.jpg') - shutil.copy(image, new_image) - .. code-block:: python import pathlib @@ -62,7 +35,8 @@ See the :doc:`api-reference` for full usage details. client_access_key=client_access_key, client_secret_key=client_secret_key, ) - name = 'my_image_name' + import uuid + name = 'my_image_name' + uuid.uuid4().hex image = pathlib.Path('high_quality_image.jpg') with image.open(mode='rb') as my_image_file: @@ -79,36 +53,17 @@ See the :doc:`api-reference` for full usage details. assert matching_targets[0].target_id == target_id a = 1 -.. invisible-code-block: python - - new_image = pathlib.Path('high_quality_image.jpg') - new_image.unlink() - stack.close() - Testing ------- To write unit tests for code which uses this library, without using your Vuforia quota, you can use the `VWS Python Mock`_ tool: -.. prompt:: bash +.. code-block:: console - pip3 install vws-python-mock + $ pip install vws-python-mock .. clear-namespace -.. invisible-code-block: python - - import pathlib - import shutil - - import vws_test_fixtures - - # We rely on implementation details of the fixtures package. - image = pathlib.Path(vws_test_fixtures.__path__[0]) / 'high_quality_image.jpg' - assert image.exists(), image.resolve() - new_image = pathlib.Path('high_quality_image.jpg') - shutil.copy(image, new_image) - .. code-block:: python import pathlib @@ -129,7 +84,6 @@ To write unit tests for code which uses this library, without using your Vuforia client_secret_key=database.client_secret_key, ) - image = pathlib.Path('high_quality_image.jpg') with image.open(mode='rb') as my_image_file: target_id = vws_client.add_target( @@ -140,11 +94,6 @@ To write unit tests for code which uses this library, without using your Vuforia active_flag=True, ) -.. invisible-code-block: python - - new_image = pathlib.Path('high_quality_image.jpg') - new_image.unlink() - There are some differences between the mock and the real Vuforia. See https://vws-python-mock.readthedocs.io/en/latest/differences-to-vws.html for details. diff --git a/docs/source/release-process.rst b/docs/source/release-process.rst index d947f9fd3..0c662f553 100644 --- a/docs/source/release-process.rst +++ b/docs/source/release-process.rst @@ -14,9 +14,9 @@ Perform a Release #. Perform a release: - .. prompt:: bash + .. code-block:: console :substitutions: - gh workflow run release.yml --repo |github-owner|/|github-repository| + $ gh workflow run release.yml --repo |github-owner|/|github-repository| .. _Install GitHub CLI: https://cli.github.com/ diff --git a/lint.mk b/lint.mk deleted file mode 100644 index f0e82e1cc..000000000 --- a/lint.mk +++ /dev/null @@ -1,65 +0,0 @@ -# Make commands for linting - -SHELL := /bin/bash -euxo pipefail - -.PHONY: mypy -mypy: - mypy . - -.PHONY: check-manifest -check-manifest: - check-manifest . - -.PHONY: doc8 -doc8: - doc8 . - -.PHONY: ruff -ruff: - ruff . - ruff format --check . - -.PHONY: fix-ruff -fix-ruff: - ruff --fix . - ruff format . - -.PHONY: pip-extra-reqs -pip-extra-reqs: - pip-extra-reqs --requirements-file=<(pdm export --pyproject) src/ - -.PHONY: pip-missing-reqs -pip-missing-reqs: - pip-missing-reqs --requirements-file=<(pdm export --pyproject) src/ - -.PHONY: pylint -pylint: - pylint *.py src/ tests/ docs/ - -.PHONY: pyroma -pyroma: - pyroma --min 10 . - -.PHONY: pyright -pyright: - pyright . - -.PHONY: vulture -vulture: - vulture --min-confidence 100 --exclude _vendor --exclude .eggs . - -.PHONY: linkcheck -linkcheck: - $(MAKE) -C docs/ linkcheck SPHINXOPTS=$(SPHINXOPTS) - -.PHONY: pyproject-fmt - pyproject-fmt: - pyproject-fmt --keep-full-version --check --indent=4 pyproject.toml - - .PHONY: fix-pyproject-fmt - fix-pyproject-fmt: - pyproject-fmt --keep-full-version --indent=4 pyproject.toml - -.PHONY: spelling -spelling: - $(MAKE) -C docs/ spelling SPHINXOPTS=$(SPHINXOPTS) diff --git a/pyproject.toml b/pyproject.toml index 414629242..ebabd99e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,209 +1,25 @@ -[tool.pylint] - - [tool.pylint.'MASTER'] - - # Pickle collected data for later comparisons. - persistent = true - - # Use multiple processes to speed up Pylint. - jobs = 0 - - # List of plugins (as comma separated values of python modules names) to load, - # usually to register additional checkers. - load-plugins = [ - 'pylint.extensions.docparams', - 'pylint.extensions.no_self_use', - ] - - # Allow loading of arbitrary C extensions. Extensions are imported into the - # active Python interpreter and may run arbitrary code. - unsafe-load-any-extension = false - - [tool.pylint.'MESSAGES CONTROL'] - - # Enable the message, report, category or checker with the given id(s). You can - # either give multiple identifier separated by comma (,) or put this option - # multiple time (only on the command line, not in the configuration file where - # it should appear only once). See also the "--disable" option for examples. - enable = [ - 'spelling', - 'useless-suppression', - ] - - # Disable the message, report, category or checker with the given id(s). You - # can either give multiple identifiers separated by comma (,) or put this - # option multiple times (only on the command line, not in the configuration - # file where it should appear only once).You can also use "--disable=all" to - # disable everything first and then reenable specific checks. For example, if - # you want to run only the similarities checker, you can use "--disable=all - # --enable=similarities". If you want to run only the classes checker, but have - # no Warning level messages displayed, use"--disable=all --enable=classes - # --disable=W" - - disable = [ - 'too-few-public-methods', - 'too-many-locals', - 'too-many-arguments', - 'too-many-instance-attributes', - 'too-many-return-statements', - 'too-many-lines', - 'locally-disabled', - # Let ruff handle long lines - 'line-too-long', - # Let ruff handle unused imports - 'unused-import', - # Let ruff deal with sorting - 'ungrouped-imports', - # We don't need everything to be documented because of mypy - 'missing-type-doc', - 'missing-return-type-doc', - # Too difficult to please - 'duplicate-code', - # Let ruff handle imports - 'wrong-import-order', - # mypy does not want untyped parameters. - 'useless-type-doc', - ] - - [tool.pylint.'FORMAT'] - - # Allow the body of an if to be on the same line as the test if there is no - # else. - single-line-if-stmt = false - - [tool.pylint.'SPELLING'] - - # Spelling dictionary name. Available dictionaries: none. To make it working - # install python-enchant package. - spelling-dict = 'en_US' - - # A path to a file that contains private dictionary; one word per line. - spelling-private-dict-file = 'spelling_private_dict.txt' - - # Tells whether to store unknown words to indicated private dictionary in - # --spelling-private-dict-file option instead of raising a message. - spelling-store-unknown-words = 'no' - -[tool.coverage.run] - -branch = true - -[tool.coverage.report] -exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"] - -[tool.pytest.ini_options] - -xfail_strict = true -log_cli = true - -[tool.check-manifest] - -ignore = [ - "*.enc", - "readthedocs.yaml", - "CHANGELOG.rst", - "CODE_OF_CONDUCT.rst", - "CONTRIBUTING.rst", - "LICENSE", - "Makefile", - "ci", - "ci/**", - "codecov.yaml", - "doc8.ini", - "docs", - "docs/**", - ".git_archival.txt", - "spelling_private_dict.txt", - "tests", - "tests-pylintrc", - "tests/**", - "vuforia_secrets.env.example", - "lint.mk", -] - -[tool.doc8] - -max_line_length = 2000 -ignore_path = [ - "./.eggs", - "./docs/build", - "./docs/build/spelling/output.txt", - "./node_modules", - "./src/*.egg-info/", - "./src/*/_setuptools_scm_version.txt", -] - -[tool.mypy] - -strict = true - [build-system] build-backend = "setuptools.build_meta" requires = [ "pip", "setuptools", - "setuptools_scm[toml]>=7.1", + "setuptools-scm[toml]>=7.1", "wheel", ] -[tool.distutils.bdist_wheel] -universal = true - -[tool.ruff] -select = ["ALL"] -line-length = 79 - -ignore = [ - # We do not annotate the type of 'self'. - "ANN101", - # We are happy to manage our own "complexity". - "C901", - # Allow our chosen docstring line-style - no one-line summary. - "D200", - "D203", - "D205", - "D212", - "D213", - # It is too much work to make every docstring imperative. - "D401", - # We ignore some docstyle errors which do not apply to Google style - # docstrings. - "D406", - "D407", - # We have an existing interface to support and so we do not want to change - # exception names. - "N818", - # Ignore "too-many-*" errors as they seem to get in the way more than - # helping. - "PLR0913", - # Allow 'assert' as we use it for tests. - "S101", - # Allow simple random numbers as we are not using them for cryptography. - "S311", -] - -# Do not automatically remove commented out code. -# We comment out code during development, and with VSCode auto-save, this code -# is sometimes annoyingly removed. -unfixable = ["ERA001"] - -[tool.ruff.per-file-ignores] -"tests/test_*.py" = [ - # Do not require tests to have a one-line summary. - "D205", -] - [project] name = "vws-python" description = "Interact with the Vuforia Web Services (VWS) API." -readme = { file = "README.rst", content-type = "text/x-rst"} +readme = { file = "README.rst", content-type = "text/x-rst" } keywords = [ "client", "vuforia", "vws", ] license = { file = "LICENSE" } -authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com"} ] +authors = [ + { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, +] requires-python = ">=3.12" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -217,57 +33,288 @@ dynamic = [ "version", ] dependencies = [ + "beartype>=0.18.5", "requests", "urllib3", - "VWS-Auth-Tools", + "vws-auth-tools", ] -[project.optional-dependencies] -dev = [ +optional-dependencies.dev = [ + "actionlint-py==1.7.1.15", "check-manifest==0.49", - "doc8==1.1.1", - "dodgy==0.2.1", - "freezegun==1.4.0", - "furo==2023.9.10", - "mypy==1.8.0", - "pdm==2.11.1", - "pip_check_reqs==2.5.3", + "deptry==0.20.0", + "doc8==1.1.2", + "freezegun==1.5.1", + "furo==2024.8.6", + "interrogate==1.7.0", + "mypy==1.11.2", + "pre-commit==3.8.0", "pydocstyle==6.3", "pyenchant==3.2.2", - "Pygments==2.17.2", - "pylint==3.0.3", - "pyproject-fmt==1.5.3", - "pyright==1.1.343", + "pygments==2.18.0", + "pylint==3.2.7", + "pyproject-fmt==2.2.1", + "pyright==1.1.378", "pyroma==4.2", - "pytest==7.4.3", - "pytest-cov==4.1", - "PyYAML==6.0.1", - "ruff==0.1.9", - "Sphinx==7.2.6", - "sphinx-autodoc-typehints==1.25.2", - "sphinx-prompt==1.8", - "Sphinx-Substitution-Extensions==2022.2.16", + "pytest==8.3.2", + "pytest-cov==5.0.0", + "pyyaml==6.0.2", + "ruff==0.6.3", + "sphinx==8.0.2", + "sphinx-copybutton==0.5.2", + "sphinx-substitution-extensions==2024.8.6", "sphinxcontrib-spelling==8", - "sybil==6.0.2", - "types-requests==2.31.0.10", - "vulture==2.10", - "VWS-Python-Mock==2023.5.21", - "VWS-Test-Fixtures==2023.3.5", + "sybil==6.1.1", + "types-requests==2.32.0.20240712", + "vulture==2.11", + "vws-python-mock==2024.8.30", + "vws-test-fixtures==2023.3.5", ] -[project.urls] -Documentation = "https://vws-python.readthedocs.io/en/latest/" -Source = "https://github.com/VWS-Python/vws-python" +urls.Documentation = "https://vws-python.readthedocs.io/en/latest/" +urls.Source = "https://github.com/VWS-Python/vws-python" [tool.setuptools] zip-safe = false [tool.setuptools.packages.find] -where = ["src"] +where = [ + "src", +] [tool.setuptools.package-data] -vws = ["py.typed"] +vws = [ + "py.typed", +] + +[tool.distutils.bdist_wheel] +universal = true [tool.setuptools_scm] +[tool.ruff] +line-length = 79 + +lint.select = [ + "ALL", +] +lint.ignore = [ + # We do not annotate the type of 'self'. + "ANN101", + # We are happy to manage our own "complexity". + "C901", + # Ruff warns that this conflicts with the formatter. + "COM812", + # Allow our chosen docstring line-style - no one-line summary. + "D200", + "D205", + "D212", + "D415", + # Ruff warns that this conflicts with the formatter. + "ISC001", + # Ignore "too-many-*" errors as they seem to get in the way more than + # helping. + "PLR0913", + # Allow 'assert' as we use it for tests. + "S101", +] + +lint.per-file-ignores."conftest.py" = [ + # Allow hardcoded secrets in tests. + "S106", +] + +lint.per-file-ignores."tests/*.py" = [ + # Do not require tests to have a one-line summary. + "D205", +] + +# Do not automatically remove commented out code. +# We comment out code during development, and with VSCode auto-save, this code +# is sometimes annoyingly removed. +lint.unfixable = [ + "ERA001", +] +lint.pydocstyle.convention = "google" + +[tool.pylint] + +[tool.pylint.'MASTER'] + +# Pickle collected data for later comparisons. +persistent = true + +# Use multiple processes to speed up Pylint. +jobs = 0 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +# See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. +# We do not use the plugins: +# - pylint.extensions.code_style +# - pylint.extensions.magic_value +# - pylint.extensions.while_used +# as they seemed to get in the way. +load-plugins = [ + 'pylint.extensions.bad_builtin', + 'pylint.extensions.comparison_placement', + 'pylint.extensions.consider_refactoring_into_while_condition', + 'pylint.extensions.docparams', + 'pylint.extensions.dunder', + 'pylint.extensions.eq_without_hash', + 'pylint.extensions.for_any_all', + 'pylint.extensions.mccabe', + 'pylint.extensions.no_self_use', + 'pylint.extensions.overlapping_exceptions', + 'pylint.extensions.private_import', + 'pylint.extensions.redefined_loop_name', + 'pylint.extensions.redefined_variable_type', + 'pylint.extensions.set_membership', + 'pylint.extensions.typing', +] + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension = false + +[tool.pylint.'MESSAGES CONTROL'] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable = [ + 'bad-inline-option', + 'deprecated-pragma', + 'file-ignored', + 'spelling', + 'use-symbolic-message-instead', + 'useless-suppression', +] + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" + +disable = [ + 'too-few-public-methods', + 'too-many-locals', + 'too-many-arguments', + 'too-many-instance-attributes', + 'too-many-return-statements', + 'too-many-lines', + 'locally-disabled', + # Let ruff handle long lines + 'line-too-long', + # Let ruff handle unused imports + 'unused-import', + # Let ruff deal with sorting + 'ungrouped-imports', + # We don't need everything to be documented because of mypy + 'missing-type-doc', + 'missing-return-type-doc', + # Too difficult to please + 'duplicate-code', + # Let ruff handle imports + 'wrong-import-order', + # mypy does not want untyped parameters. + 'useless-type-doc', +] + +[tool.pylint.'FORMAT'] + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt = false + +[tool.pylint.'SPELLING'] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict = 'en_US' + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file = 'spelling_private_dict.txt' + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words = 'no' + +[tool.check-manifest] + +ignore = [ + "*.enc", + ".pre-commit-config.yaml", + "readthedocs.yaml", + "CHANGELOG.rst", + "CODE_OF_CONDUCT.rst", + "CONTRIBUTING.rst", + "LICENSE", + "Makefile", + "ci", + "ci/**", + "codecov.yaml", + "doc8.ini", + "docs", + "docs/**", + ".git_archival.txt", + "spelling_private_dict.txt", + "tests", + "tests-pylintrc", + "tests/**", + "vuforia_secrets.env.example", + "lint.mk", +] + +[tool.deptry] +pep621_dev_dependency_groups = [ + "dev", +] + +[tool.pyproject-fmt] +indent = 4 +keep_full_version = true + +[tool.pytest.ini_options] + +xfail_strict = true +log_cli = true + +[tool.coverage.run] + +branch = true + +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:", +] + +[tool.mypy] + +strict = true + [tool.pyright] +reportUnnecessaryTypeIgnoreComment = true typeCheckingMode = "strict" + +[tool.interrogate] +fail-under = 100 +omit-covered-files = true +verbose = 2 + +[tool.doc8] + +max_line_length = 2000 +ignore_path = [ + "./.eggs", + "./docs/build", + "./docs/build/spelling/output.txt", + "./node_modules", + "./src/*.egg-info/", + "./src/*/_setuptools_scm_version.txt", +] diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index a11d546a6..ec77ade47 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,4 +1,3 @@ - AuthenticationFailure BadImage ConnectionErrorPossiblyImageTooLarge @@ -9,6 +8,8 @@ JSONDecodeError MatchProcessing MaxNumResultsOutOfRange MetadataTooLarge +OopsAnErrorOccurredPossiblyBadName +OopsAnErrorOccurredPossiblyBadNameError ProjectHasNoAPIAccess ProjectInactive ProjectSuspended @@ -22,11 +23,11 @@ TargetStatusProcessing TooManyRequests Ubuntu UnknownTarget -OopsAnErrorOccurredPossiblyBadName admin api args ascii +beartype bool boolean bytesio diff --git a/src/vws/exceptions/base_exceptions.py b/src/vws/exceptions/base_exceptions.py index 730d1a2a5..9dde05ee2 100644 --- a/src/vws/exceptions/base_exceptions.py +++ b/src/vws/exceptions/base_exceptions.py @@ -3,15 +3,13 @@ Cloud Recognition Web API. """ -from __future__ import annotations +from beartype import beartype -from typing import TYPE_CHECKING +from .response import Response -if TYPE_CHECKING: - from requests import Response - -class CloudRecoException(Exception): +@beartype +class CloudRecoError(Exception): """ Base class for Vuforia Cloud Recognition Web API exceptions. """ @@ -32,12 +30,13 @@ def response(self) -> Response: return self._response -class VWSException(Exception): +@beartype +class VWSError(Exception): """ Base class for Vuforia Web Services errors. These errors are defined at - https://library.vuforia.com/web-api/cloud-targets-web-services-api#result-codes. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. """ def __init__(self, response: Response) -> None: diff --git a/src/vws/exceptions/cloud_reco_exceptions.py b/src/vws/exceptions/cloud_reco_exceptions.py index 625f91c1d..ff5dde209 100644 --- a/src/vws/exceptions/cloud_reco_exceptions.py +++ b/src/vws/exceptions/cloud_reco_exceptions.py @@ -2,39 +2,45 @@ Exceptions which match errors raised by the Vuforia Cloud Recognition Web APIs. """ +from beartype import beartype -from vws.exceptions.base_exceptions import CloudRecoException +from vws.exceptions.base_exceptions import CloudRecoError -class MaxNumResultsOutOfRange(CloudRecoException): +@beartype +class MaxNumResultsOutOfRangeError(CloudRecoError): """ Exception raised when the ``max_num_results`` given to the Cloud Recognition Web API query endpoint is out of range. """ -class InactiveProject(CloudRecoException): +@beartype +class InactiveProjectError(CloudRecoError): """ Exception raised when Vuforia returns a response with a result code 'InactiveProject'. """ -class BadImage(CloudRecoException): +@beartype +class BadImageError(CloudRecoError): """ Exception raised when Vuforia returns a response with a result code 'BadImage'. """ -class AuthenticationFailure(CloudRecoException): +@beartype +class AuthenticationFailureError(CloudRecoError): """ Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. """ -class RequestTimeTooSkewed(CloudRecoException): +@beartype +class RequestTimeTooSkewedError(CloudRecoError): """ Exception raised when Vuforia returns a response with a result code 'RequestTimeTooSkewed'. diff --git a/src/vws/exceptions/custom_exceptions.py b/src/vws/exceptions/custom_exceptions.py index ef88d53aa..d036d8750 100644 --- a/src/vws/exceptions/custom_exceptions.py +++ b/src/vws/exceptions/custom_exceptions.py @@ -1,14 +1,16 @@ """ Exceptions which do not map to errors at -https://library.vuforia.com/web-api/cloud-targets-web-services-api#result-codes +https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes or simple errors given by the cloud recognition service. """ +from beartype import beartype -from requests import Response +from .response import Response -class OopsAnErrorOccurredPossiblyBadName(Exception): +@beartype +class OopsAnErrorOccurredPossiblyBadNameError(Exception): """ Exception raised when VWS returns an HTML page which says "Oops, an error occurred". @@ -32,13 +34,52 @@ def response(self) -> Response: return self._response -class RequestEntityTooLarge(Exception): +@beartype +class RequestEntityTooLargeError(Exception): """ Exception raised when the given image is too large. """ + def __init__(self, response: Response) -> None: + """ + Args: + response: The response returned by Vuforia. + """ + super().__init__(response.text) + self._response = response -class TargetProcessingTimeout(Exception): + @property + def response(self) -> Response: + """ + The response returned by Vuforia which included this error. + """ + return self._response + + +@beartype +class TargetProcessingTimeoutError(Exception): """ Exception raised when waiting for a target to be processed times out. """ + + +@beartype +class ServerError(Exception): # pragma: no cover + """ + Exception raised when VWS returns a server error. + """ + + def __init__(self, response: Response) -> None: + """ + Args: + response: The response returned by Vuforia. + """ + super().__init__(response.text) + self._response = response + + @property + def response(self) -> Response: + """ + The response returned by Vuforia which included this error. + """ + return self._response diff --git a/src/vws/exceptions/response.py b/src/vws/exceptions/response.py new file mode 100644 index 000000000..0b3eced88 --- /dev/null +++ b/src/vws/exceptions/response.py @@ -0,0 +1,19 @@ +"""Responses for exceptions.""" + +from dataclasses import dataclass + +from beartype import beartype + + +@dataclass +@beartype +class Response: + """ + A response from a request. + """ + + text: str + url: str + status_code: int + headers: dict[str, str] + request_body: bytes | str | None diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 9ff990255..bf6d5fc4d 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -1,16 +1,19 @@ """ Exception raised when Vuforia returns a response with a result code matching one of those documented at -https://library.vuforia.com/web-api/cloud-targets-web-services-api#result-codes. +https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. """ import json from urllib.parse import urlparse -from vws.exceptions.base_exceptions import VWSException +from beartype import beartype +from vws.exceptions.base_exceptions import VWSError -class UnknownTarget(VWSException): + +@beartype +class UnknownTargetError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'UnknownTarget'. @@ -21,27 +24,30 @@ def target_id(self) -> str: """ The unknown target ID. """ - path = urlparse(self.response.url).path + path = urlparse(url=self.response.url).path # Every HTTP path which can raise this error is in the format # `/something/{target_id}`. return path.split(sep="/", maxsplit=2)[-1] -class Fail(VWSException): +@beartype +class FailError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'Fail'. """ -class BadImage(VWSException): +@beartype +class BadImageError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'BadImage'. """ -class AuthenticationFailure(VWSException): +@beartype +class AuthenticationFailureError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'AuthenticationFailure'. @@ -49,21 +55,16 @@ class AuthenticationFailure(VWSException): # See https://github.com/VWS-Python/vws-python/issues/822. -class RequestQuotaReached(VWSException): # pragma: no cover +@beartype +class RequestQuotaReachedError(VWSError): # pragma: no cover """ Exception raised when Vuforia returns a response with a result code 'RequestQuotaReached'. """ -class TooManyRequests(VWSException): # pragma: no cover - """ - Exception raised when Vuforia returns a response with a result code - 'TooManyRequests'. - """ - - -class TargetStatusProcessing(VWSException): +@beartype +class TargetStatusProcessingError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'TargetStatusProcessing'. @@ -74,14 +75,15 @@ def target_id(self) -> str: """ The processing target ID. """ - path = urlparse(self.response.url).path + path = urlparse(url=self.response.url).path # Every HTTP path which can raise this error is in the format # `/something/{target_id}`. return path.split(sep="/", maxsplit=2)[-1] # This is not simulated by the mock. -class DateRangeError(VWSException): # pragma: no cover +@beartype +class DateRangeError(VWSError): # pragma: no cover """ Exception raised when Vuforia returns a response with a result code 'DateRangeError'. @@ -89,7 +91,8 @@ class DateRangeError(VWSException): # pragma: no cover # This is not simulated by the mock. -class TargetQuotaReached(VWSException): # pragma: no cover +@beartype +class TargetQuotaReachedError(VWSError): # pragma: no cover """ Exception raised when Vuforia returns a response with a result code 'TargetQuotaReached'. @@ -97,7 +100,8 @@ class TargetQuotaReached(VWSException): # pragma: no cover # This is not simulated by the mock. -class ProjectSuspended(VWSException): # pragma: no cover +@beartype +class ProjectSuspendedError(VWSError): # pragma: no cover """ Exception raised when Vuforia returns a response with a result code 'ProjectSuspended'. @@ -105,35 +109,40 @@ class ProjectSuspended(VWSException): # pragma: no cover # This is not simulated by the mock. -class ProjectHasNoAPIAccess(VWSException): # pragma: no cover +@beartype +class ProjectHasNoAPIAccessError(VWSError): # pragma: no cover """ Exception raised when Vuforia returns a response with a result code 'ProjectHasNoAPIAccess'. """ -class ProjectInactive(VWSException): +@beartype +class ProjectInactiveError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'ProjectInactive'. """ -class MetadataTooLarge(VWSException): +@beartype +class MetadataTooLargeError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'MetadataTooLarge'. """ -class RequestTimeTooSkewed(VWSException): +@beartype +class RequestTimeTooSkewedError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'RequestTimeTooSkewed'. """ -class TargetNameExist(VWSException): +@beartype +class TargetNameExistError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'TargetNameExist'. @@ -144,19 +153,21 @@ def target_name(self) -> str: """ The target name which already exists. """ - response_body = self.response.request.body or b"" - request_json = json.loads(response_body) + response_body = self.response.request_body or b"" + request_json = json.loads(s=response_body) return str(request_json["name"]) -class ImageTooLarge(VWSException): +@beartype +class ImageTooLargeError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'ImageTooLarge'. """ -class TargetStatusNotSuccess(VWSException): +@beartype +class TargetStatusNotSuccessError(VWSError): """ Exception raised when Vuforia returns a response with a result code 'TargetStatusNotSuccess'. @@ -167,7 +178,15 @@ def target_id(self) -> str: """ The unknown target ID. """ - path = urlparse(self.response.url).path + path = urlparse(url=self.response.url).path # Every HTTP path which can raise this error is in the format # `/something/{target_id}`. return path.split(sep="/", maxsplit=2)[-1] + + +@beartype +class TooManyRequestsError(VWSError): # pragma: no cover + """ + Exception raised when Vuforia returns a response with a result code + 'TooManyRequests'. + """ diff --git a/src/vws/include_target_data.py b/src/vws/include_target_data.py index bff82c926..e259f654a 100644 --- a/src/vws/include_target_data.py +++ b/src/vws/include_target_data.py @@ -2,10 +2,12 @@ Tools for managing ``CloudRecoService.query``'s ``include_target_data``. """ - from enum import StrEnum, auto +from beartype import beartype + +@beartype class CloudRecoIncludeTargetData(StrEnum): """ Options for the ``include_target_data`` parameter of diff --git a/src/vws/query.py b/src/vws/query.py index 5df811252..fc42a3ff5 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -2,32 +2,39 @@ Tools for interacting with the Vuforia Cloud Recognition Web APIs. """ -from __future__ import annotations - import datetime -from http import HTTPStatus +import io +import json +from http import HTTPMethod, HTTPStatus from typing import Any, BinaryIO from urllib.parse import urljoin import requests +from beartype import beartype from urllib3.filepost import encode_multipart_formdata from vws_auth_tools import authorization_header, rfc_1123_date from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailure, - BadImage, - InactiveProject, - MaxNumResultsOutOfRange, - RequestTimeTooSkewed, + AuthenticationFailureError, + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, + RequestTimeTooSkewedError, ) from vws.exceptions.custom_exceptions import ( - RequestEntityTooLarge, + RequestEntityTooLargeError, + ServerError, ) +from vws.exceptions.response import Response from vws.include_target_data import CloudRecoIncludeTargetData from vws.reports import QueryResult, TargetData +_ImageType = io.BytesIO | BinaryIO + -def _get_image_data(image: BinaryIO) -> bytes: +@beartype +def _get_image_data(image: _ImageType) -> bytes: + """Get the data of an image file.""" original_tell = image.tell() image.seek(0) image_data = image.read() @@ -35,6 +42,7 @@ def _get_image_data(image: BinaryIO) -> bytes: return image_data +@beartype class CloudRecoService: """ An interface to the Vuforia Cloud Recognition Web APIs. @@ -58,7 +66,7 @@ def __init__( def query( self, - image: BinaryIO, + image: _ImageType, max_num_results: int = 1, include_target_data: CloudRecoIncludeTargetData = ( CloudRecoIncludeTargetData.TOP @@ -68,7 +76,7 @@ def query( Use the Vuforia Web Query API to make an Image Recognition Query. See - https://library.vuforia.com/web-api/vuforia-query-web-api + https://developer.vuforia.com/library/web-api/vuforia-query-web-api for parameter details. Args: @@ -81,19 +89,21 @@ def query( none (return no target_data), all (for all matched targets). Raises: - ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailure: The - client access key pair is not correct. - ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRange: + ~vws.exceptions.cloud_reco_exceptions.AuthenticationFailureError: + The client access key pair is not correct. + ~vws.exceptions.cloud_reco_exceptions.MaxNumResultsOutOfRangeError: ``max_num_results`` is not within the range (1, 50). - ~vws.exceptions.cloud_reco_exceptions.InactiveProject: The project - is inactive. - ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewed: There - is an error with the time sent to Vuforia. - ~vws.exceptions.cloud_reco_exceptions.BadImage: There is a problem - with the given image. For example, it must be a JPEG or PNG - file in the grayscale or RGB color space. - ~vws.exceptions.custom_exceptions.RequestEntityTooLarge: The given - image is too large. + ~vws.exceptions.cloud_reco_exceptions.InactiveProjectError: The + project is inactive. + ~vws.exceptions.cloud_reco_exceptions.RequestTimeTooSkewedError: + There is an error with the time sent to Vuforia. + ~vws.exceptions.cloud_reco_exceptions.BadImageError: There is a + problem with the given image. For example, it must be a JPEG or + PNG file in the grayscale or RGB color space. + ~vws.exceptions.custom_exceptions.RequestEntityTooLargeError: The + given image is too large. + ~vws.exceptions.custom_exceptions.ServerError: There is an + error with Vuforia's servers. Returns: An ordered list of target details of matching targets. @@ -111,7 +121,7 @@ def query( date = rfc_1123_date() request_path = "/v1/query" content, content_type_header = encode_multipart_formdata(fields=body) - method = "POST" + method = HTTPMethod.POST authorization_string = authorization_header( access_key=self._client_access_key, @@ -130,33 +140,45 @@ def query( "Content-Type": content_type_header, } - response = requests.request( + requests_response = requests.request( method=method, url=urljoin(base=self._base_vwq_url, url=request_path), headers=headers, data=content, # We should make the timeout customizable. - timeout=None, + timeout=30, + ) + response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, ) if response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: - raise RequestEntityTooLarge + raise RequestEntityTooLargeError(response=response) if "Integer out of range" in response.text: - raise MaxNumResultsOutOfRange(response=response) + raise MaxNumResultsOutOfRangeError(response=response) + + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) - result_code = response.json()["result_code"] + result_code = json.loads(s=response.text)["result_code"] if result_code != "Success": exception = { - "AuthenticationFailure": AuthenticationFailure, - "BadImage": BadImage, - "InactiveProject": InactiveProject, - "RequestTimeTooSkewed": RequestTimeTooSkewed, + "AuthenticationFailure": AuthenticationFailureError, + "BadImage": BadImageError, + "InactiveProject": InactiveProjectError, + "RequestTimeTooSkewed": RequestTimeTooSkewedError, }[result_code] raise exception(response=response) result: list[QueryResult] = [] - result_list = list(response.json()["results"]) + result_list = list(json.loads(s=response.text)["results"]) for item in result_list: target_data: TargetData | None = None if "target_data" in item: @@ -164,7 +186,7 @@ def query( metadata = target_data_dict["application_metadata"] timestamp_string = target_data_dict["target_timestamp"] target_timestamp = datetime.datetime.fromtimestamp( - timestamp_string, + timestamp=timestamp_string, tz=datetime.UTC, ) target_data = TargetData( diff --git a/src/vws/reports.py b/src/vws/reports.py index 479690cdd..3d157a783 100644 --- a/src/vws/reports.py +++ b/src/vws/reports.py @@ -6,14 +6,17 @@ from dataclasses import dataclass from enum import Enum +from beartype import BeartypeConf, beartype + +@beartype @dataclass class DatabaseSummaryReport: """ A database summary report. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#summary-report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. """ active_images: int @@ -30,11 +33,12 @@ class DatabaseSummaryReport: total_recos: int +@beartype class TargetStatuses(Enum): """Constants representing VWS target statuses. See the 'status' field in - https://library.vuforia.com/web-api/cloud-targets-web-services-api#target-record + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record """ PROCESSING = "processing" @@ -42,13 +46,14 @@ class TargetStatuses(Enum): FAILED = "failed" +@beartype @dataclass class TargetSummaryReport: """ A target summary report. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#summary-report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. """ status: TargetStatuses @@ -62,13 +67,14 @@ class TargetSummaryReport: previous_month_recos: int +@beartype(conf=BeartypeConf(is_pep484_tower=True)) @dataclass class TargetRecord: """ A target record. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#target-record. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. """ target_id: str @@ -79,6 +85,7 @@ class TargetRecord: reco_rating: str +@beartype @dataclass class TargetData: """ @@ -90,26 +97,28 @@ class TargetData: target_timestamp: datetime.datetime +@beartype @dataclass class QueryResult: """ One query match result. See - https://library.vuforia.com/web-api/vuforia-query-web-api. + https://developer.vuforia.com/library/web-api/vuforia-query-web-api. """ target_id: str target_data: TargetData | None +@beartype @dataclass class TargetStatusAndRecord: """ The target status and a target record. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#target-record. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. """ status: TargetStatuses diff --git a/src/vws/vws.py b/src/vws/vws.py index 267f88411..9879102cf 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -2,42 +2,42 @@ Tools for interacting with Vuforia APIs. """ -from __future__ import annotations - import base64 +import io import json import time from datetime import date -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO +from http import HTTPMethod, HTTPStatus +from typing import BinaryIO from urllib.parse import urljoin import requests -from requests import Response +from beartype import BeartypeConf, beartype from vws_auth_tools import authorization_header, rfc_1123_date from vws.exceptions.custom_exceptions import ( - OopsAnErrorOccurredPossiblyBadName, - TargetProcessingTimeout, + OopsAnErrorOccurredPossiblyBadNameError, + ServerError, + TargetProcessingTimeoutError, ) from vws.exceptions.vws_exceptions import ( - AuthenticationFailure, - BadImage, + AuthenticationFailureError, + BadImageError, DateRangeError, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectHasNoAPIAccess, - ProjectInactive, - ProjectSuspended, - RequestQuotaReached, - RequestTimeTooSkewed, - TargetNameExist, - TargetQuotaReached, - TargetStatusNotSuccess, - TargetStatusProcessing, - TooManyRequests, - UnknownTarget, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectHasNoAPIAccessError, + ProjectInactiveError, + ProjectSuspendedError, + RequestQuotaReachedError, + RequestTimeTooSkewedError, + TargetNameExistError, + TargetQuotaReachedError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + TooManyRequestsError, + UnknownTargetError, ) from vws.reports import ( DatabaseSummaryReport, @@ -47,11 +47,14 @@ TargetSummaryReport, ) -if TYPE_CHECKING: - import io +from .exceptions.response import Response + +_ImageType = io.BytesIO | BinaryIO -def _get_image_data(image: BinaryIO) -> bytes: +@beartype +def _get_image_data(image: _ImageType) -> bytes: + """Get the data of an image file.""" original_tell = image.tell() image.seek(0) image_data = image.read() @@ -59,11 +62,12 @@ def _get_image_data(image: BinaryIO) -> bytes: return image_data +@beartype def _target_api_request( server_access_key: str, server_secret_key: str, method: str, - content: bytes, + data: bytes, request_path: str, base_vws_url: str, ) -> Response: @@ -77,7 +81,7 @@ def _target_api_request( server_access_key: A VWS server access key. server_secret_key: A VWS server secret key. method: The HTTP method which will be used in the request. - content: The request body which will be used in the request. + data: The request body which will be used in the request. request_path: The path to the endpoint which will be used in the request. base_vws_url: The base URL for the VWS API. @@ -92,7 +96,7 @@ def _target_api_request( access_key=server_access_key, secret_key=server_secret_key, method=method, - content=content, + content=data, content_type=content_type, date=date_string, request_path=request_path, @@ -106,16 +110,25 @@ def _target_api_request( url = urljoin(base=base_vws_url, url=request_path) - return requests.request( + requests_response = requests.request( method=method, url=url, headers=headers, - data=content, + data=data, # We should make the timeout customizable. - timeout=None, + timeout=30, + ) + + return Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, ) +@beartype(conf=BeartypeConf(is_pep484_tower=True)) class VWS: """ An interface to Vuforia Web Services APIs. @@ -137,79 +150,90 @@ def __init__( self._server_secret_key = server_secret_key self._base_vws_url = base_vws_url - def _make_request( + def make_request( self, method: str, - content: bytes, + data: bytes, request_path: str, expected_result_code: str, ) -> Response: """ Make a request to the Vuforia Target API. - This uses `requests` to make a request against https://vws.vuforia.com. + This uses `requests` to make a request against Vuforia. The content type of the request will be `application/json`. Args: method: The HTTP method which will be used in the request. - content: The request body which will be used in the request. + data: The request body which will be used in the request. request_path: The path to the endpoint which will be used in the request. - expected_result_code: See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#result-codes + expected_result_code: See "VWS API Result Codes" on + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api. Returns: The response to the request made by `requests`. Raises: - ~vws.exceptions.OopsAnErrorOccurredPossiblyBadName: Vuforia returns - an HTML page with the text "Oops, an error occurred". This has - been seen to happen when the given name includes a bad + ~vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadNameError: + Vuforia returns an HTML page with the text "Oops, an error + occurred". + + This has been seen to happen when the given name includes a bad character. - json.decoder.JSONDecodeError: The server did not respond with valid - JSON. This may happen if the server address is not a valid - Vuforia server. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. + json.JSONDecodeError: The server did not respond with valid JSON. + This may happen if the server address is not a valid Vuforia + server. """ response = _target_api_request( server_access_key=self._server_access_key, server_secret_key=self._server_secret_key, method=method, - content=content, + data=data, request_path=request_path, base_vws_url=self._base_vws_url, ) if "Oops, an error occurred" in response.text: - raise OopsAnErrorOccurredPossiblyBadName(response=response) + raise OopsAnErrorOccurredPossiblyBadNameError(response=response) if ( response.status_code == HTTPStatus.TOO_MANY_REQUESTS ): # pragma: no cover # The Vuforia API returns a 429 response with no JSON body. - raise TooManyRequests(response=response) + raise TooManyRequestsError(response=response) - result_code = response.json()["result_code"] + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) + + result_code = json.loads(s=response.text)["result_code"] if result_code == expected_result_code: return response exception = { - "AuthenticationFailure": AuthenticationFailure, - "BadImage": BadImage, + "AuthenticationFailure": AuthenticationFailureError, + "BadImage": BadImageError, "DateRangeError": DateRangeError, - "Fail": Fail, - "ImageTooLarge": ImageTooLarge, - "MetadataTooLarge": MetadataTooLarge, - "ProjectHasNoAPIAccess": ProjectHasNoAPIAccess, - "ProjectInactive": ProjectInactive, - "ProjectSuspended": ProjectSuspended, - "RequestQuotaReached": RequestQuotaReached, - "RequestTimeTooSkewed": RequestTimeTooSkewed, - "TargetNameExist": TargetNameExist, - "TargetQuotaReached": TargetQuotaReached, - "TargetStatusNotSuccess": TargetStatusNotSuccess, - "TargetStatusProcessing": TargetStatusProcessing, - "UnknownTarget": UnknownTarget, + "Fail": FailError, + "ImageTooLarge": ImageTooLargeError, + "MetadataTooLarge": MetadataTooLargeError, + "ProjectHasNoAPIAccess": ProjectHasNoAPIAccessError, + "ProjectInactive": ProjectInactiveError, + "ProjectSuspended": ProjectSuspendedError, + "RequestQuotaReached": RequestQuotaReachedError, + "RequestTimeTooSkewed": RequestTimeTooSkewedError, + "TargetNameExist": TargetNameExistError, + "TargetQuotaReached": TargetQuotaReachedError, + "TargetStatusNotSuccess": TargetStatusNotSuccessError, + "TargetStatusProcessing": TargetStatusProcessingError, + "UnknownTarget": UnknownTargetError, }[result_code] raise exception(response=response) @@ -218,7 +242,7 @@ def add_target( self, name: str, width: float, - image: BinaryIO, + image: _ImageType, application_metadata: str | None, *, active_flag: bool, @@ -227,7 +251,7 @@ def add_target( Add a target to a Vuforia Web Services database. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#add + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#add for parameter details. Args: @@ -244,33 +268,38 @@ def add_target( The target ID of the new target. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.BadImage: There is a problem with - the given image. - For example, it must be a JPEG or PNG file in the grayscale or - RGB color space. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.BadImageError: There is a problem + with the given image. For example, it must be a JPEG or PNG + file in the grayscale or RGB color space. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.MetadataTooLarge: The given metadata - is too large. The maximum size is 1 MB of data when Base64 - encoded. - ~vws.exceptions.vws_exceptions.ImageTooLarge: The given image is - too large. - ~vws.exceptions.vws_exceptions.TargetNameExist: A target with the - given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactive: The project is + ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given + metadata is too large. The maximum size is 1 MB of data when + Base64 encoded. + ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image + is too large. + ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with + the given ``name`` already exists. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is inactive. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. - ~vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName: + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadNameError: Vuforia returns an HTML page with the text "Oops, an error occurred". This has been seen to happen when the given name includes a bad character. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ image_data = _get_image_data(image=image) - image_data_encoded = base64.b64encode(image_data).decode("ascii") + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) data = { "name": name, @@ -280,23 +309,23 @@ def add_target( "application_metadata": application_metadata, } - content = bytes(json.dumps(data), encoding="utf-8") + content = json.dumps(obj=data).encode(encoding="utf-8") - response = self._make_request( - method="POST", - content=content, + response = self.make_request( + method=HTTPMethod.POST, + data=content, request_path="/targets", expected_result_code="TargetCreated", ) - return str(response.json()["target_id"]) + return str(json.loads(s=response.text)["target_id"]) def get_target_record(self, target_id: str) -> TargetStatusAndRecord: """ Get a given target's target record from the Target Management System. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#target-record. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#target-record. Args: target_id: The ID of the target to get details of. @@ -305,25 +334,29 @@ def get_target_record(self, target_id: str) -> TargetStatusAndRecord: Response details of a target from Vuforia. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method="GET", - content=b"", + response = self.make_request( + method=HTTPMethod.GET, + data=b"", request_path=f"/targets/{target_id}", expected_result_code="Success", ) - result_data = response.json() - status = TargetStatuses(result_data["status"]) + result_data = json.loads(s=response.text) + status = TargetStatuses(value=result_data["status"]) target_record_dict = dict(result_data["target_record"]) target_record = TargetRecord( target_id=target_record_dict["target_id"], @@ -359,18 +392,22 @@ def wait_for_target_processed( target to be processed. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.custom_exceptions.TargetProcessingTimeout: The + ~vws.exceptions.custom_exceptions.TargetProcessingTimeoutError: The target remained in the processing stage for more than ``timeout_seconds`` seconds. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ start_time = time.monotonic() while True: @@ -380,7 +417,7 @@ def wait_for_target_processed( elapsed_time = time.monotonic() - start_time if elapsed_time > timeout_seconds: # pragma: no cover - raise TargetProcessingTimeout + raise TargetProcessingTimeoutError time.sleep(seconds_between_requests) @@ -389,35 +426,39 @@ def list_targets(self) -> list[str]: List target IDs. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#details-list. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#details-list. Returns: The IDs of all targets in the database. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method="GET", - content=b"", + response = self.make_request( + method=HTTPMethod.GET, + data=b"", request_path="/targets", expected_result_code="Success", ) - return list(response.json()["results"]) + return list(json.loads(s=response.text)["results"]) def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: """ Get a summary report for a target. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#summary-report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. Args: target_id: The ID of the target to get a summary report for. @@ -426,26 +467,30 @@ def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: Details of the target. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method="GET", - content=b"", + response = self.make_request( + method=HTTPMethod.GET, + data=b"", request_path=f"/summary/{target_id}", expected_result_code="Success", ) - result_data = dict(response.json()) + result_data = dict(json.loads(s=response.text)) return TargetSummaryReport( - status=TargetStatuses(result_data["status"]), + status=TargetStatuses(value=result_data["status"]), database_name=result_data["database_name"], target_name=result_data["target_name"], upload_date=date.fromisoformat(result_data["upload_date"]), @@ -461,28 +506,32 @@ def get_database_summary_report(self) -> DatabaseSummaryReport: Get a summary report for the database. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#summary-report. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#summary-report. Returns: Details of the database. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method="GET", - content=b"", + response = self.make_request( + method=HTTPMethod.GET, + data=b"", request_path="/summary", expected_result_code="Success", ) - response_data = dict(response.json()) + response_data = dict(json.loads(s=response.text)) return DatabaseSummaryReport( active_images=response_data["active_images"], current_month_recos=response_data["current_month_recos"], @@ -503,27 +552,31 @@ def delete_target(self, target_id: str) -> None: Delete a given target. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#delete. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#delete. Args: target_id: The ID of the target to delete. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.TargetStatusProcessing: The given - target is in the processing state. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.TargetStatusProcessingError: The + given target is in the processing state. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - self._make_request( - method="DELETE", - content=b"", + self.make_request( + method=HTTPMethod.DELETE, + data=b"", request_path=f"/targets/{target_id}", expected_result_code="Success", ) @@ -533,7 +586,7 @@ def get_duplicate_targets(self, target_id: str) -> list[str]: Get targets which may be considered duplicates of a given target. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#check. + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check. Args: target_id: The ID of the target to delete. @@ -542,45 +595,49 @@ def get_duplicate_targets(self, target_id: str) -> list[str]: The target IDs of duplicate targets. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.ProjectInactive: The project is + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is inactive. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method="GET", - content=b"", + response = self.make_request( + method=HTTPMethod.GET, + data=b"", request_path=f"/duplicates/{target_id}", expected_result_code="Success", ) - return list(response.json()["similar_targets"]) + return list(json.loads(s=response.text)["similar_targets"]) def update_target( self, target_id: str, name: str | None = None, width: float | None = None, - image: io.BytesIO | None = None, + image: _ImageType | None = None, active_flag: bool | None = None, application_metadata: str | None = None, ) -> None: """ - Add a target to a Vuforia Web Services database. + Update a target in a Vuforia Web Services database. See - https://library.vuforia.com/web-api/cloud-targets-web-services-api#add + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#update for parameter details. Args: - target_id: The ID of the target to get details of. + target_id: The ID of the target to update. name: The name of the target. width: The width of the target. image: The image of the target. @@ -593,25 +650,29 @@ def update_target( Giving ``None`` will not change the application metadata. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.BadImage: There is a problem with - the given image. For example, it must be a JPEG or PNG file in - the grayscale or RGB color space. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.BadImageError: There is a problem + with the given image. For example, it must be a JPEG or PNG + file in the grayscale or RGB color space. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.MetadataTooLarge: The given metadata - is too large. The maximum size is 1 MB of data when Base64 - encoded. - ~vws.exceptions.vws_exceptions.ImageTooLarge: The given image is - too large. - ~vws.exceptions.vws_exceptions.TargetNameExist: A target with the - given ``name`` already exists. - ~vws.exceptions.vws_exceptions.ProjectInactive: The project is + ~vws.exceptions.vws_exceptions.MetadataTooLargeError: The given + metadata is too large. The maximum size is 1 MB of data when + Base64 encoded. + ~vws.exceptions.vws_exceptions.ImageTooLargeError: The given image + is too large. + ~vws.exceptions.vws_exceptions.TargetNameExistError: A target with + the given ``name`` already exists. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is inactive. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ data: dict[str, str | bool | float | int] = {} @@ -623,7 +684,9 @@ def update_target( if image is not None: image_data = _get_image_data(image=image) - image_data_encoded = base64.b64encode(image_data).decode("ascii") + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) data["image"] = image_data_encoded if active_flag is not None: @@ -632,11 +695,11 @@ def update_target( if application_metadata is not None: data["application_metadata"] = application_metadata - content = bytes(json.dumps(data), encoding="utf-8") + content = json.dumps(obj=data).encode(encoding="utf-8") - self._make_request( - method="PUT", - content=content, + self.make_request( + method=HTTPMethod.PUT, + data=content, request_path=f"/targets/{target_id}", expected_result_code="Success", ) diff --git a/tests/conftest.py b/tests/conftest.py index ac8181bd7..0ec595e31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,19 +2,16 @@ Configuration, plugins and fixtures for `pytest`. """ -from __future__ import annotations - import io -from typing import TYPE_CHECKING, BinaryIO +from collections.abc import Generator +from pathlib import Path +from typing import BinaryIO, Literal import pytest from mock_vws import MockVWS from mock_vws.database import VuforiaDatabase -from vws import VWS, CloudRecoService -if TYPE_CHECKING: - from collections.abc import Generator - from pathlib import Path +from vws import VWS, CloudRecoService @pytest.fixture(name="_mock_database") @@ -22,13 +19,14 @@ def mock_database() -> Generator[VuforiaDatabase, None, None]: """ Yield a mock ``VuforiaDatabase``. """ - with MockVWS() as mock: + # We use a low processing time so that tests run quickly. + with MockVWS(processing_time_seconds=0.2) as mock: database = VuforiaDatabase() mock.add_database(database=database) yield database -@pytest.fixture() +@pytest.fixture def vws_client(_mock_database: VuforiaDatabase) -> VWS: """ A VWS client which connects to a mock database. @@ -39,7 +37,7 @@ def vws_client(_mock_database: VuforiaDatabase) -> VWS: ) -@pytest.fixture() +@pytest.fixture def cloud_reco_client(_mock_database: VuforiaDatabase) -> CloudRecoService: """ A ``CloudRecoService`` client which connects to a mock database. @@ -50,25 +48,28 @@ def cloud_reco_client(_mock_database: VuforiaDatabase) -> CloudRecoService: ) -@pytest.fixture() -def image_file( +@pytest.fixture(name="image_file", params=["r+b", "rb"]) +def image_file_fixture( high_quality_image: io.BytesIO, tmp_path: Path, -) -> Generator[io.BufferedRandom, None, None]: + request: pytest.FixtureRequest, +) -> Generator[BinaryIO, None, None]: """An image file object.""" file = tmp_path / "image.jpg" - file.touch() - with file.open("r+b") as fileobj: - buffer = high_quality_image.getvalue() - fileobj.write(buffer) - yield fileobj + buffer = high_quality_image.getvalue() + file.write_bytes(data=buffer) + mode: Literal["r+b", "rb"] = request.param + with file.open(mode=mode) as file_obj: + yield file_obj @pytest.fixture(params=["high_quality_image", "image_file"]) def image( request: pytest.FixtureRequest, -) -> BinaryIO: + high_quality_image: io.BytesIO, + image_file: BinaryIO, +) -> io.BytesIO | BinaryIO: """An image in any of the types that the API accepts.""" - result = request.getfixturevalue(request.param) - assert isinstance(result, io.BytesIO | io.BufferedRandom) - return result + if request.param == "high_quality_image": + return high_quality_image + return image_file diff --git a/tests/test_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py index 7e7261599..5c0a89cd2 100644 --- a/tests/test_cloud_reco_exceptions.py +++ b/tests/test_cloud_reco_exceptions.py @@ -2,32 +2,28 @@ Tests for exceptions raised when using the CloudRecoService. """ -from __future__ import annotations - +import io import uuid from http import HTTPStatus -from typing import TYPE_CHECKING import pytest from mock_vws import MockVWS from mock_vws.database import VuforiaDatabase from mock_vws.states import States + from vws import CloudRecoService -from vws.exceptions.base_exceptions import CloudRecoException +from vws.exceptions.base_exceptions import CloudRecoError from vws.exceptions.cloud_reco_exceptions import ( - AuthenticationFailure, - BadImage, - InactiveProject, - MaxNumResultsOutOfRange, - RequestTimeTooSkewed, + AuthenticationFailureError, + BadImageError, + InactiveProjectError, + MaxNumResultsOutOfRangeError, + RequestTimeTooSkewedError, ) from vws.exceptions.custom_exceptions import ( - RequestEntityTooLarge, + RequestEntityTooLargeError, ) -if TYPE_CHECKING: - import io - def test_too_many_max_results( cloud_reco_client: CloudRecoService, @@ -37,7 +33,7 @@ def test_too_many_max_results( A ``MaxNumResultsOutOfRange`` error is raised if the given ``max_num_results`` is out of range. """ - with pytest.raises(MaxNumResultsOutOfRange) as exc: + with pytest.raises(MaxNumResultsOutOfRangeError) as exc: cloud_reco_client.query( image=high_quality_image, max_num_results=51, @@ -52,32 +48,38 @@ def test_too_many_max_results( def test_image_too_large( cloud_reco_client: CloudRecoService, - png_too_large: io.BytesIO, + png_too_large: io.BytesIO | io.BufferedRandom, ) -> None: """ A ``RequestEntityTooLarge`` exception is raised if an image which is too large is given. """ - with pytest.raises(RequestEntityTooLarge): + with pytest.raises(RequestEntityTooLargeError) as exc: cloud_reco_client.query(image=png_too_large) + assert ( + exc.value.response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + ) + def test_cloudrecoexception_inheritance() -> None: """ CloudRecoService-specific exceptions inherit from CloudRecoException. """ subclasses = [ - MaxNumResultsOutOfRange, - InactiveProject, - BadImage, - AuthenticationFailure, - RequestTimeTooSkewed, + MaxNumResultsOutOfRangeError, + InactiveProjectError, + BadImageError, + AuthenticationFailureError, + RequestTimeTooSkewedError, ] for subclass in subclasses: - assert issubclass(subclass, CloudRecoException) + assert issubclass(subclass, CloudRecoError) -def test_authentication_failure(high_quality_image: io.BytesIO) -> None: +def test_authentication_failure( + high_quality_image: io.BytesIO, +) -> None: """ An ``AuthenticationFailure`` exception is raised when the client access key exists but the client secret key is incorrect. @@ -90,13 +92,15 @@ def test_authentication_failure(high_quality_image: io.BytesIO) -> None: with MockVWS() as mock: mock.add_database(database=database) - with pytest.raises(AuthenticationFailure) as exc: + with pytest.raises(AuthenticationFailureError) as exc: cloud_reco_client.query(image=high_quality_image) assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED -def test_inactive_project(high_quality_image: io.BytesIO) -> None: +def test_inactive_project( + high_quality_image: io.BytesIO, +) -> None: """ An ``InactiveProject`` exception is raised when querying an inactive database. @@ -109,7 +113,7 @@ def test_inactive_project(high_quality_image: io.BytesIO) -> None: client_secret_key=database.client_secret_key, ) - with pytest.raises(InactiveProject) as exc: + with pytest.raises(InactiveProjectError) as exc: cloud_reco_client.query(image=high_quality_image) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN diff --git a/tests/test_query.py b/tests/test_query.py index 0a4357db5..e229cf95d 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,19 +2,16 @@ Tests for the ``CloudRecoService`` querying functionality. """ -from __future__ import annotations - +import io import uuid -from typing import TYPE_CHECKING +from typing import BinaryIO from mock_vws import MockVWS from mock_vws.database import VuforiaDatabase + from vws import VWS, CloudRecoService from vws.include_target_data import CloudRecoIncludeTargetData -if TYPE_CHECKING: - import io - class TestQuery: """ @@ -24,7 +21,7 @@ class TestQuery: @staticmethod def test_no_matches( cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ An empty list is returned if there are no matches. @@ -36,7 +33,7 @@ def test_no_matches( def test_match( vws_client: VWS, cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ Details of matching targets are returned. @@ -59,7 +56,7 @@ class TestCustomBaseVWQURL: """ @staticmethod - def test_custom_base_url(image: io.BytesIO) -> None: + def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: """ It is possible to use query a target to a database under a custom VWQ URL. @@ -104,7 +101,7 @@ class TestMaxNumResults: def test_default( vws_client: VWS, cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ By default the maximum number of results is 1. @@ -132,7 +129,7 @@ def test_default( def test_custom( vws_client: VWS, cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ It is possible to set a custom ``max_num_results``. @@ -178,7 +175,7 @@ class TestIncludeTargetData: def test_default( vws_client: VWS, cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ By default, target data is only returned in the top match. @@ -210,7 +207,7 @@ def test_default( def test_top( vws_client: VWS, cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ When ``CloudRecoIncludeTargetData.TOP`` is given, target data is only @@ -244,7 +241,7 @@ def test_top( def test_none( vws_client: VWS, cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ When ``CloudRecoIncludeTargetData.NONE`` is given, target data is not @@ -278,7 +275,7 @@ def test_none( def test_all( vws_client: VWS, cloud_reco_client: CloudRecoService, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ When ``CloudRecoIncludeTargetData.ALL`` is given, target data is diff --git a/tests/test_vws.py b/tests/test_vws.py index 7c148cf29..c5761b78a 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -2,20 +2,20 @@ Tests for helper functions for managing a Vuforia database. """ -from __future__ import annotations - import base64 import datetime -import random +import io +import secrets import uuid -from typing import TYPE_CHECKING, BinaryIO +from typing import BinaryIO import pytest from freezegun import freeze_time from mock_vws import MockVWS from mock_vws.database import VuforiaDatabase + from vws import VWS, CloudRecoService -from vws.exceptions.custom_exceptions import TargetProcessingTimeout +from vws.exceptions.custom_exceptions import TargetProcessingTimeoutError from vws.reports import ( DatabaseSummaryReport, TargetRecord, @@ -23,9 +23,6 @@ TargetSummaryReport, ) -if TYPE_CHECKING: - import io - class TestAddTarget: """ @@ -37,7 +34,7 @@ class TestAddTarget: @pytest.mark.parametrize("active_flag", [True, False]) def test_add_target( vws_client: VWS, - image: BinaryIO, + image: io.BytesIO | BinaryIO, application_metadata: bytes | None, cloud_reco_client: CloudRecoService, *, @@ -51,8 +48,8 @@ def test_add_target( if application_metadata is None: encoded_metadata = None else: - encoded_metadata_bytes = base64.b64encode(application_metadata) - encoded_metadata = encoded_metadata_bytes.decode("utf-8") + encoded_metadata_bytes = base64.b64encode(s=application_metadata) + encoded_metadata = encoded_metadata_bytes.decode(encoding="utf-8") target_id = vws_client.add_target( name=name, @@ -81,7 +78,7 @@ def test_add_target( @staticmethod def test_add_two_targets( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ No exception is raised when adding two targets with different names. @@ -104,7 +101,7 @@ class TestCustomBaseVWSURL: """ @staticmethod - def test_custom_base_url(image: io.BytesIO) -> None: + def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: """ It is possible to use add a target to a database under a custom VWS URL. @@ -136,7 +133,7 @@ class TestListTargets: @staticmethod def test_list_targets( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ It is possible to get a list of target IDs. @@ -166,7 +163,7 @@ class TestDelete: @staticmethod def test_delete_target( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ It is possible to delete a target. @@ -193,14 +190,14 @@ class TestGetTargetSummaryReport: @staticmethod def test_get_target_summary_report( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ Details of a target are returned by ``get_target_summary_report``. """ date = "2018-04-25" target_name = uuid.uuid4().hex - with freeze_time(date): + with freeze_time(time_to_freeze=date): target_id = vws_client.add_target( name=target_name, width=1, @@ -262,7 +259,7 @@ class TestGetTargetRecord: @staticmethod def test_get_target_record( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ Details of a target are returned by ``get_target_record``. @@ -297,7 +294,7 @@ class TestWaitForTargetProcessed: @staticmethod def test_wait_for_target_processed( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ It is possible to wait until a target is processed. @@ -317,7 +314,7 @@ def test_wait_for_target_processed( @staticmethod def test_default_seconds_between_requests( - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ By default, 0.2 seconds are waited between polling requests. @@ -369,7 +366,7 @@ def test_default_seconds_between_requests( @staticmethod def test_custom_seconds_between_requests( - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ It is possible to customize the time waited between polling requests. @@ -420,7 +417,7 @@ def test_custom_seconds_between_requests( assert report.request_usage == expected_requests @staticmethod - def test_custom_timeout(image: io.BytesIO) -> None: + def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None: """ It is possible to set a maximum timeout. """ @@ -442,7 +439,7 @@ def test_custom_timeout(image: io.BytesIO) -> None: report = vws_client.get_target_summary_report(target_id=target_id) assert report.status == TargetStatuses.PROCESSING - with pytest.raises(TargetProcessingTimeout): + with pytest.raises(TargetProcessingTimeoutError): vws_client.wait_for_target_processed( target_id=target_id, timeout_seconds=0.1, @@ -464,7 +461,7 @@ class TestGetDuplicateTargets: @staticmethod def test_get_duplicate_targets( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ It is possible to get the IDs of similar targets. @@ -498,7 +495,7 @@ class TestUpdateTarget: @staticmethod def test_update_target( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, different_high_quality_image: io.BytesIO, cloud_reco_client: CloudRecoService, ) -> None: @@ -506,7 +503,7 @@ def test_update_target( It is possible to update a target. """ old_name = uuid.uuid4().hex - old_width = random.uniform(a=0.01, b=50) + old_width = secrets.choice(seq=range(1, 5000)) / 100 target_id = vws_client.add_target( name=old_name, width=old_width, @@ -523,8 +520,10 @@ def test_update_target( assert query_metadata is None new_name = uuid.uuid4().hex - new_width = random.uniform(a=0.01, b=50) - new_application_metadata = base64.b64encode(b"a").decode("ascii") + new_width = secrets.choice(seq=range(1, 5000)) / 100 + new_application_metadata = base64.b64encode(s=b"a").decode( + encoding="ascii", + ) vws_client.update_target( target_id=target_id, name=new_name, @@ -557,7 +556,7 @@ def test_update_target( @staticmethod def test_no_fields_given( vws_client: VWS, - image: io.BytesIO, + image: io.BytesIO | BinaryIO, ) -> None: """ It is possible to give no update fields. diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 469683f6c..0c9de2d29 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -11,38 +11,41 @@ from mock_vws import MockVWS from mock_vws.database import VuforiaDatabase from mock_vws.states import States + from vws import VWS -from vws.exceptions.base_exceptions import VWSException -from vws.exceptions.custom_exceptions import OopsAnErrorOccurredPossiblyBadName +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.custom_exceptions import ( + OopsAnErrorOccurredPossiblyBadNameError, +) from vws.exceptions.vws_exceptions import ( - AuthenticationFailure, - BadImage, + AuthenticationFailureError, + BadImageError, DateRangeError, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectHasNoAPIAccess, - ProjectInactive, - ProjectSuspended, - RequestQuotaReached, - RequestTimeTooSkewed, - TargetNameExist, - TargetQuotaReached, - TargetStatusNotSuccess, - TargetStatusProcessing, - UnknownTarget, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectHasNoAPIAccessError, + ProjectInactiveError, + ProjectSuspendedError, + RequestQuotaReachedError, + RequestTimeTooSkewedError, + TargetNameExistError, + TargetQuotaReachedError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + UnknownTargetError, ) def test_image_too_large( vws_client: VWS, - png_too_large: io.BytesIO, + png_too_large: io.BytesIO | io.BufferedRandom, ) -> None: """ When giving an image which is too large, an ``ImageTooLarge`` exception is raised. """ - with pytest.raises(ImageTooLarge) as exc: + with pytest.raises(ImageTooLargeError) as exc: vws_client.add_target( name="x", width=1, @@ -60,7 +63,7 @@ def test_invalid_given_id(vws_client: VWS) -> None: causes an ``UnknownTarget`` exception to be raised. """ target_id = "12345abc" - with pytest.raises(UnknownTarget) as exc: + with pytest.raises(UnknownTargetError) as exc: vws_client.delete_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.NOT_FOUND assert exc.value.target_id == target_id @@ -73,7 +76,7 @@ def test_add_bad_name(vws_client: VWS, high_quality_image: io.BytesIO) -> None: """ max_char_value = 65535 bad_name = chr(max_char_value + 1) - with pytest.raises(OopsAnErrorOccurredPossiblyBadName) as exc: + with pytest.raises(OopsAnErrorOccurredPossiblyBadNameError) as exc: vws_client.add_target( name=bad_name, width=1, @@ -102,7 +105,7 @@ def test_fail(high_quality_image: io.BytesIO) -> None: server_secret_key=uuid.uuid4().hex, ) - with pytest.raises(Fail) as exc: + with pytest.raises(FailError) as exc: vws_client.add_target( name="x", width=1, @@ -118,8 +121,8 @@ def test_bad_image(vws_client: VWS) -> None: """ A ``BadImage`` exception is raised when a non-image is given. """ - not_an_image = io.BytesIO(b"Not an image") - with pytest.raises(BadImage) as exc: + not_an_image = io.BytesIO(initial_bytes=b"Not an image") + with pytest.raises(BadImageError) as exc: vws_client.add_target( name="x", width=1, @@ -146,7 +149,7 @@ def test_target_name_exist( active_flag=True, application_metadata=None, ) - with pytest.raises(TargetNameExist) as exc: + with pytest.raises(TargetNameExistError) as exc: vws_client.add_target( name="x", width=1, @@ -159,7 +162,9 @@ def test_target_name_exist( assert exc.value.target_name == "x" -def test_project_inactive(high_quality_image: io.BytesIO) -> None: +def test_project_inactive( + high_quality_image: io.BytesIO, +) -> None: """ A ``ProjectInactive`` exception is raised if adding a target to an inactive database. @@ -172,7 +177,7 @@ def test_project_inactive(high_quality_image: io.BytesIO) -> None: server_secret_key=database.server_secret_key, ) - with pytest.raises(ProjectInactive) as exc: + with pytest.raises(ProjectInactiveError) as exc: vws_client.add_target( name="x", width=1, @@ -200,7 +205,7 @@ def test_target_status_processing( application_metadata=None, ) - with pytest.raises(TargetStatusProcessing) as exc: + with pytest.raises(TargetStatusProcessingError) as exc: vws_client.delete_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN @@ -215,7 +220,7 @@ def test_metadata_too_large( A ``MetadataTooLarge`` exception is raised if the metadata given is too large. """ - with pytest.raises(MetadataTooLarge) as exc: + with pytest.raises(MetadataTooLargeError) as exc: vws_client.add_target( name="x", width=1, @@ -255,14 +260,16 @@ def test_request_time_too_skewed( # >= 1 ticks are acceptable. with ( freeze_time(auto_tick_seconds=time_difference_from_now), - pytest.raises(RequestTimeTooSkewed) as exc, + pytest.raises(RequestTimeTooSkewedError) as exc, ): vws_client.get_target_record(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN -def test_authentication_failure(high_quality_image: io.BytesIO) -> None: +def test_authentication_failure( + high_quality_image: io.BytesIO, +) -> None: """ An ``AuthenticationFailure`` exception is raised when the server access key exists but the server secret key is incorrect, or when a client key is @@ -278,7 +285,7 @@ def test_authentication_failure(high_quality_image: io.BytesIO) -> None: with MockVWS() as mock: mock.add_database(database=database) - with pytest.raises(AuthenticationFailure) as exc: + with pytest.raises(AuthenticationFailureError) as exc: vws_client.add_target( name="x", width=1, @@ -306,7 +313,7 @@ def test_target_status_not_success( application_metadata=None, ) - with pytest.raises(TargetStatusNotSuccess) as exc: + with pytest.raises(TargetStatusNotSuccessError) as exc: vws_client.update_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN @@ -318,25 +325,25 @@ def test_vwsexception_inheritance() -> None: VWS-related exceptions should inherit from VWSException. """ subclasses = [ - AuthenticationFailure, - BadImage, + AuthenticationFailureError, + BadImageError, DateRangeError, - Fail, - ImageTooLarge, - MetadataTooLarge, - ProjectInactive, - ProjectHasNoAPIAccess, - ProjectSuspended, - RequestQuotaReached, - RequestTimeTooSkewed, - TargetNameExist, - TargetQuotaReached, - TargetStatusNotSuccess, - TargetStatusProcessing, - UnknownTarget, + FailError, + ImageTooLargeError, + MetadataTooLargeError, + ProjectInactiveError, + ProjectHasNoAPIAccessError, + ProjectSuspendedError, + RequestQuotaReachedError, + RequestTimeTooSkewedError, + TargetNameExistError, + TargetQuotaReachedError, + TargetStatusNotSuccessError, + TargetStatusProcessingError, + UnknownTargetError, ] for subclass in subclasses: - assert issubclass(subclass, VWSException) + assert issubclass(subclass, VWSError) def test_base_exception( @@ -346,7 +353,7 @@ def test_base_exception( """ ``VWSException``s has a response property. """ - with pytest.raises(VWSException) as exc: + with pytest.raises(VWSError) as exc: vws_client.get_target_record(target_id="a") assert exc.value.response.status_code == HTTPStatus.NOT_FOUND