diff --git a/.gitattributes b/.gitattributes index 00a7b00c9..ee0f759ba 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ .git_archival.txt export-subst +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a04c486d6..610da79f8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,14 @@ +--- version: 2 + updates: -- package-ecosystem: pip - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 -- package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 + - package-ecosystem: pip + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c5f7d90..8c8d881e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ --- -name: CI +name: Test on: push: @@ -10,49 +10,35 @@ on: schedule: # * is a special character in YAML so you have to quote this string # Run at 1:00 every day - - cron: '0 1 * * *' + - cron: 0 1 * * * jobs: build: strategy: matrix: - python-version: ["3.12"] - platform: [ubuntu-latest] + python-version: ['3.13'] + platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - - name: "Set up Python" - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- - - - 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] + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' - - name: "Lint" - run: | - make lint - - - name: "Run tests" + - 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 + uv run --extra=dev --python=${{ matrix.python-version }} pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests/ . --cov-report=xml - - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v3" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml new file mode 100644 index 000000000..5238c9f68 --- /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/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..def06bdb9 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +--- + +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # * is a special character in YAML so you have to quote this string + # Run at 1:00 every day + - cron: 0 1 * * * + +jobs: + build: + + strategy: + matrix: + python-version: ['3.13'] + platform: [ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' + + - name: Lint + run: | + uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose + uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose + uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose + env: + UV_PYTHON: ${{ matrix.python-version }} + + - uses: pre-commit-ci/lite-action@v1.1.0 + if: always() diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml new file mode 100644 index 000000000..8f5a347a4 --- /dev/null +++ b/.github/workflows/publish-site.yml @@ -0,0 +1,28 @@ +--- +name: Deploy documentation + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pages: + runs-on: ubuntu-latest + environment: + name: ${{ github.ref_name == 'main' && 'github-pages' || 'development' }} + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: sphinx-notes/pages@v3 + with: + documentation_path: docs/source + pyproject_extras: dev + python_version: '3.13' + sphinx_build_options: -W + cache: true + publish: ${{ github.ref_name == 'main' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8974001cd..e44c847b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,68 +9,95 @@ jobs: name: Publish a release runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] + # Specifying an environment is strongly recommended by PyPI. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + environment: release + + permissions: + # This is needed for PyPI publishing. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. + id-token: write + # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. + contents: write steps: - uses: actions/checkout@v4 + with: + # See + # https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches + token: ${{ secrets.RELEASE_PAT }} + # Fetch all history including tags. + # Needed to find the latest tag. + # + # Also, avoids + # https://github.com/stefanzweifel/git-auto-commit-action/issues/99. + fetch-depth: 0 - - name: "Set up Python" - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: ${{ matrix.python-version }} + enable-cache: true + cache-dependency-glob: '**/pyproject.toml' - - name: "Calver calculate version" + - name: Calver calculate version uses: StephaneBour/actions-calver@master id: calver with: - date_format: "%Y.%m.%d" + date_format: '%Y.%m.%d' release: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: "Update changelog" + - name: Get the changelog underline + id: changelog_underline + run: | + underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')" + echo "underline=${underline}" >> "$GITHUB_OUTPUT" + + - name: Update changelog uses: jacobtomlinson/gha-find-replace@v3 - env: - NEXT_VERSION: ${{ steps.calver.outputs.release }} with: find: "Next\n----" - replace: "Next\n----\n\n${{ env.NEXT_VERSION }}\n------------" - include: "CHANGELOG.rst" + replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ + \ }}" + include: CHANGELOG.rst regex: false - uses: stefanzweifel/git-auto-commit-action@v5 id: commit with: commit_message: Bump CHANGELOG + file_pattern: CHANGELOG.rst + # Error if there are no changes. + skip_dirty_check: true - name: Bump version and push tag id: tag_version - uses: mathieudutour/github-tag-action@v6.1 + uses: mathieudutour/github-tag-action@v6.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} custom_tag: ${{ steps.calver.outputs.release }} - tag_prefix: "" + tag_prefix: '' commit_sha: ${{ steps.commit.outputs.commit_hash }} - name: Create a GitHub release uses: ncipollo/release-action@v1 with: tag: ${{ steps.tag_version.outputs.new_tag }} + makeLatest: true name: Release ${{ steps.tag_version.outputs.new_tag }} body: ${{ steps.tag_version.outputs.changelog }} - name: Build a binary wheel and a source tarball 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 - python -m build --sdist --wheel --outdir dist/ . + git checkout ${{ steps.tag_version.outputs.new_tag }} + uv build --sdist --wheel --out-dir dist/ + uv run --extra=release check-wheel-contents dist/*.whl - name: Publish distribution 📦 to PyPI + # We use PyPI trusted publishing rather than a PyPI API token. + # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.PYPI_API_TOKEN }} verbose: true diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml deleted file mode 100644 index c13708757..000000000 --- a/.github/workflows/windows-ci.yml +++ /dev/null @@ -1,51 +0,0 @@ ---- - -name: Windows CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - schedule: - # * is a special character in YAML so you have to quote this string - # Run at 1:00 every day - - cron: '0 1 * * *' - -jobs: - build: - - strategy: - matrix: - python-version: ["3.12"] - platform: [windows-latest] - - runs-on: ${{ matrix.platform }} - - steps: - - uses: actions/checkout@v4 - - name: "Set up Python" - uses: actions/setup-python@v5 - 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- - - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip setuptools wheel - # We use '--ignore-installed' to avoid GitHub's cache which can cause - # issues - we have seen packages from this cache be cause trouble with - # pip-extra-reqs. - python -m pip install --ignore-installed --upgrade --editable .[dev] - - - name: "Run tests" - run: | - # 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 diff --git a/.gitignore b/.gitignore index ec1645423..556e31308 100644 --- a/.gitignore +++ b/.gitignore @@ -98,8 +98,9 @@ secrets.tar # mypy .mypy_cache/ -# macOS attributes -*.DS_Store +# Ignore Mac DS_Store files +.DS_Store +**/.DS_Store # pyre .pyre/ @@ -109,3 +110,5 @@ secrets.tar # setuptools_scm src/*/_setuptools_scm_version.txt + +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..cf9bf80da --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,314 @@ +--- +fail_fast: true + +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +ci: + # We use system Python, with required dependencies specified in pyproject.toml. + # We therefore cannot use those dependencies in pre-commit CI. + skip: + - actionlint + - sphinx-lint + - check-manifest + - deptry + - doc8 + - docformatter + - docs + - interrogate + - interrogate-docs + - linkcheck + - mypy + - mypy-docs + - pylint + - pyproject-fmt-fix + - pyright + - pyright-docs + - pyright-verifytypes + - pyroma + - ruff-check-fix + - ruff-check-fix-docs + - ruff-format-fix + - ruff-format-fix-docs + - shellcheck + - shellcheck-docs + - shfmt + - shfmt-docs + - spelling + - vulture + - vulture-docs + - yamlfix + +default_install_hook_types: [pre-commit, pre-push, commit-msg] + +repos: + - repo: meta + hooks: + - id: check-useless-excludes + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.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-json + - 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: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: rst-directive-colons + - id: rst-inline-touching-normal + - id: text-unicode-replacement-char + - id: rst-backticks + + - repo: local + hooks: + - id: actionlint + name: actionlint + entry: uv run --extra=dev actionlint + language: python + pass_filenames: false + types_or: [yaml] + additional_dependencies: [uv==0.6.3] + + - id: docformatter + name: docformatter + entry: uv run --extra=dev -m docformatter --in-place + language: python + types_or: [python] + additional_dependencies: [uv==0.6.3] + + - id: shellcheck + name: shellcheck + entry: uv run --extra=dev shellcheck --shell=bash + language: python + pass_filenames: false + types_or: [shell] + additional_dependencies: [uv==0.6.3] + + - id: shellcheck-docs + name: shellcheck-docs + entry: uv run --extra=dev doccmd --language=shell --language=console --command="shellcheck + --shell=bash" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: shfmt + name: shfmt + entry: uv run --extra=dev shfmt --write --space-redirects --indent=4 + language: python + types_or: [shell] + additional_dependencies: [uv==0.6.3] + + - id: shfmt-docs + name: shfmt-docs + entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt + --no-pad-file --command="shfmt --write --space-redirects --indent=4" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: mypy + name: mypy + stages: [pre-push] + entry: uv run --extra=dev -m mypy + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: mypy-docs + name: mypy-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --language=python --command="mypy" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: check-manifest + name: check-manifest + stages: [pre-push] + entry: uv run --extra=dev -m check_manifest + language: python + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: pyright + name: pyright + stages: [pre-push] + entry: uv run --extra=dev -m pyright . + language: python + types_or: [python, toml] + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: pyright-docs + name: pyright-docs + stages: [pre-push] + entry: uv run --extra=dev doccmd --language=python --command="pyright" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: vulture + name: vulture + entry: uv run --extra=dev -m vulture . + language: python + types_or: [python] + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: vulture-docs + name: vulture docs + entry: uv run --extra=dev doccmd --language=python --command="vulture" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: pyroma + name: pyroma + entry: uv run --extra=dev -m pyroma --min 10 . + language: python + pass_filenames: false + types_or: [toml] + additional_dependencies: [uv==0.6.3] + + - id: deptry + name: deptry + entry: uv run --extra=dev -m deptry src/ + language: python + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: pylint + name: pylint + entry: uv run --extra=dev -m pylint *.py src/ tests/ docs/ + language: python + stages: [manual] + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: pylint-docs + name: pylint-docs + entry: uv run --extra=dev doccmd --language=python --command="pylint" + language: python + stages: [manual] + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: ruff-check-fix + name: Ruff check fix + entry: uv run --extra=dev -m ruff check --fix + language: python + types_or: [python] + additional_dependencies: [uv==0.6.3] + + - id: ruff-check-fix-docs + name: Ruff check fix docs + entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: ruff-format-fix + name: Ruff format + entry: uv run --extra=dev -m ruff format + language: python + types_or: [python] + additional_dependencies: [uv==0.6.3] + + - id: ruff-format-fix-docs + name: Ruff format docs + entry: uv run --extra=dev doccmd --language=python --no-pad-file --command="ruff + format" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: doc8 + name: doc8 + entry: uv run --extra=dev -m doc8 + language: python + types_or: [rst] + additional_dependencies: [uv==0.6.3] + + - id: interrogate + name: interrogate + entry: uv run --extra=dev -m interrogate + language: python + types_or: [python] + additional_dependencies: [uv==0.6.3] + + - id: interrogate-docs + name: interrogate docs + entry: uv run --extra=dev doccmd --language=python --command="interrogate" + language: python + types_or: [markdown, rst] + additional_dependencies: [uv==0.6.3] + + - id: pyproject-fmt-fix + name: pyproject-fmt + entry: uv run --extra=dev pyproject-fmt + language: python + types_or: [toml] + files: pyproject.toml + additional_dependencies: [uv==0.6.3] + + - id: linkcheck + name: linkcheck + entry: make -C docs/ linkcheck SPHINXOPTS=-W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: spelling + name: spelling + entry: make -C docs/ spelling SPHINXOPTS=-W + language: python + types_or: [rst] + stages: [manual] + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: docs + name: Build Documentation + entry: make docs + language: python + stages: [manual] + pass_filenames: false + additional_dependencies: [uv==0.6.3] + + - id: pyright-verifytypes + name: pyright-verifytypes + stages: [pre-push] + entry: uv run --extra=dev -m pyright --verifytypes vws + language: python + pass_filenames: false + types_or: [python] + additional_dependencies: [uv==0.6.3] + + - id: yamlfix + name: yamlfix + entry: uv run --extra=dev yamlfix + language: python + types_or: [yaml] + additional_dependencies: [uv==0.6.3] + + - id: sphinx-lint + name: sphinx-lint + entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long + language: python + types_or: [rst] + additional_dependencies: [uv==0.6.3] diff --git a/.vscode/settings.json b/.vscode/settings.json index a05041473..69abf060e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,15 @@ { "[python]": { "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" }, "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true }, - "esbonio.sphinx.confDir": "" + "esbonio.sphinx.confDir": "", + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 281270708..5527f9da1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,56 @@ Changelog Next ---- +2025.03.10.1 +------------ + +2025.03.10 +---------- + +* Removed ``vws.exceptions.custom_exceptions.OopsAnErrorOccurredPossiblyBadName`` which now does not occur in VWS. + +2024.09.21 +------------ + +2024.09.04.1 +------------ + +2024.09.04 +------------ + +* Move ``Response`` from ``vws.exceptions.response`` to ``vws.types``. +* Add ``raw`` field to ``Response``. + +2024.09.03 +------------ + +* 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..3cd5bb22c 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|Build Status| |codecov| |PyPI| |Documentation Status| +|Build Status| |codecov| |PyPI| vws-python ========== @@ -9,68 +9,46 @@ Web Query API. Installation ------------ -.. code:: sh +.. code-block:: shell pip install vws-python -This is tested on Python 3.12+. Get in touch with +This is tested on Python |minimum-python-version|\+. Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. Getting Started --------------- -.. 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 + """Add a target to VWS and then query it.""" + + import os import pathlib + import uuid from vws import VWS, CloudRecoService - server_access_key = '[server-access-key]' - server_secret_key = '[server-secret-key]' - client_access_key = '[client-access-key]' - client_secret_key = '[client-secret-key]' + server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] + server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] + client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] + client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] vws_client = VWS( server_access_key=server_access_key, server_secret_key=server_secret_key, ) + cloud_reco_client = CloudRecoService( client_access_key=client_access_key, client_secret_key=client_secret_key, ) - name = 'my_image_name' - image = pathlib.Path('high_quality_image.jpg') - with image.open(mode='rb') as my_image_file: + name = "my_image_name_" + uuid.uuid4().hex + + image = pathlib.Path("high_quality_image.jpg") + with image.open(mode="rb") as my_image_file: target_id = vws_client.add_target( name=name, width=1, @@ -78,27 +56,23 @@ Getting Started active_flag=True, application_metadata=None, ) - vws_client.wait_for_target_processed(target_id=target_id) + + vws_client.wait_for_target_processed(target_id=target_id) + + with image.open(mode="rb") as my_image_file: matching_targets = cloud_reco_client.query(image=my_image_file) assert matching_targets[0].target_id == target_id -.. invisible-code-block: python - new_image = pathlib.Path('high_quality_image.jpg') - new_image.unlink() - stack.close() - Full Documentation ------------------ -See the `full -documentation `__. +See the `full documentation `__. -.. |Build Status| image:: https://github.com/VWS-Python/vws-python/workflows/CI/badge.svg +.. |Build Status| image:: https://github.com/VWS-Python/vws-python/actions/workflows/ci.yml/badge.svg?branch=main :target: https://github.com/VWS-Python/vws-python/actions .. |codecov| image:: https://codecov.io/gh/VWS-Python/vws-python/branch/main/graph/badge.svg :target: https://codecov.io/gh/VWS-Python/vws-python .. |PyPI| image:: https://badge.fury.io/py/VWS-Python.svg :target: https://badge.fury.io/py/VWS-Python -.. |Documentation Status| image:: https://readthedocs.org/projects/vws-python/badge/?version=latest - :target: https://vws-python.readthedocs.io/en/latest/?badge=latest +.. |minimum-python-version| replace:: 3.13 diff --git a/codecov.yaml b/codecov.yaml index e49034f39..5c35baac9 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -1,3 +1,4 @@ +--- coverage: status: patch: diff --git a/conftest.py b/conftest.py index 9d744c035..1d776f8db 100644 --- a/conftest.py +++ b/conftest.py @@ -1,19 +1,85 @@ -"""Setup for Sybil.""" +""" +Setup for Sybil. +""" +import io +import uuid +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(name="make_image_file") +def fixture_make_image_file( + high_quality_image: io.BytesIO, +) -> Generator[None]: + """Make an image file available in the test directory. + + The path of this file matches the path in the documentation. + """ + new_image = Path("high_quality_image.jpg") + buffer = high_quality_image.getvalue() + new_image.write_bytes(data=buffer) + yield + new_image.unlink() + + +@pytest.fixture(name="mock_vws") +def fixture_mock_vws( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Yield a mock VWS. + + The keys used here match the keys in the documentation. + """ + server_access_key = uuid.uuid4().hex + server_secret_key = uuid.uuid4().hex + client_access_key = uuid.uuid4().hex + client_secret_key = uuid.uuid4().hex + + database = 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, + ) + + monkeypatch.setenv(name="VWS_SERVER_ACCESS_KEY", value=server_access_key) + monkeypatch.setenv(name="VWS_SERVER_SECRET_KEY", value=server_secret_key) + monkeypatch.setenv(name="VWS_CLIENT_ACCESS_KEY", value=client_access_key) + monkeypatch.setenv(name="VWS_CLIENT_SECRET_KEY", value=client_secret_key) + # 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/Makefile b/docs/Makefile index 6225d81e0..15e9c44b1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -10,11 +10,11 @@ BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @uv run --extra=dev $(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @uv run --extra=dev $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/__init__.py b/docs/source/__init__.py index 535ceb2ec..b63eed5fb 100644 --- a/docs/source/__init__.py +++ b/docs/source/__init__.py @@ -1 +1,3 @@ -"""Documentation.""" +""" +Documentation. +""" diff --git a/docs/source/api-reference.rst b/docs/source/api-reference.rst index 3ffbc13da..01bba09b2 100644 --- a/docs/source/api-reference.rst +++ b/docs/source/api-reference.rst @@ -12,3 +12,7 @@ API Reference .. automodule:: vws.include_target_data :undoc-members: :members: + +.. automodule:: vws.response + :undoc-members: + :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index c3861a1bf..8c7e78f05 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,20 +3,26 @@ Configuration for Sphinx. """ -# pylint: disable=invalid-name - -import datetime import importlib.metadata +from pathlib import Path + +from packaging.specifiers import SpecifierSet +from sphinx_pyproject import SphinxConfig -project = "VWS-Python" -author = "Adam Dangoor" +_pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" +_pyproject_config = SphinxConfig( + pyproject_file=_pyproject_file, + config_overrides={"version": None}, +) + +project = _pyproject_config.name +author = _pyproject_config.author extensions = [ + "sphinx_copybutton", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", - "sphinx_autodoc_typehints", - "sphinx-prompt", "sphinx_substitution_extensions", "sphinxcontrib.spelling", ] @@ -25,18 +31,22 @@ source_suffix = ".rst" master_doc = "index" -year = datetime.datetime.now(tz=datetime.UTC).year -project_copyright = f"{year}, {author}" +project_copyright = f"%Y, {author}" + +# Exclude the prompt from copied code with sphinx_copybutton. +# https://sphinx-copybutton.readthedocs.io/en/latest/use.html#automatic-exclusion-of-prompts-from-the-copies. +copybutton_exclude = ".linenos, .gp" -# 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. -version = importlib.metadata.version(distribution_name=project) -_month, _day, _year, *_ = version.split(".") -release = f"{_month}.{_day}.{_year}" +project_metadata = importlib.metadata.metadata(distribution_name=project) +requires_python = project_metadata["Requires-Python"] +specifiers = SpecifierSet(specifiers=requires_python) +(specifier,) = specifiers +if specifier.operator != ">=": + msg = ( + f"We only support '>=' for Requires-Python, got {specifier.operator}." + ) + raise ValueError(msg) +minimum_python_version = specifier.version language = "en" @@ -54,28 +64,17 @@ # Output file base name for HTML help builder. htmlhelp_basename = "VWSPYTHONdoc" -autoclass_content = "init" intersphinx_mapping = { - "python": ("https://docs.python.org/3.12", None), + "python": (f"https://docs.python.org/{minimum_python_version}", None), } nitpicky = True +nitpick_ignore = (("py:class", "_io.BytesIO"),) warning_is_error = True -nitpick_ignore = [ - ("py:class", "_io.BytesIO"), - # Requests documentation exposes ``requests.Response``, not - # ``requests.models.response``. - ("py:class", "requests.models.Response"), -] - 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" @@ -83,9 +82,7 @@ rst_prolog = f""" .. |project| replace:: {project} -.. |release| replace:: {release} +.. |minimum-python-version| replace:: {minimum_python_version} .. |github-owner| replace:: VWS-Python .. |github-repository| replace:: vws-python """ - -always_document_param_types = True diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index ca559e843..21e5dbe82 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 pre-commit --verbose + $ pre-commit run --all-files --hook-stage pre-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/index.rst b/docs/source/index.rst index 630355629..818b0698d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,11 +4,11 @@ Installation ------------ -.. prompt:: bash +.. code-block:: console - pip3 install vws-python + $ pip install vws-python -This is tested on Python 3.8+. +This is tested on Python |minimum-python-version|\+. Get in touch with ``adamdangoor@gmail.com`` if you would like to use this with another language. Usage @@ -16,141 +16,104 @@ 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 + """Add a target to VWS and then query it.""" + + import os import pathlib + import uuid from vws import VWS, CloudRecoService - server_access_key = '[server-access-key]' - server_secret_key = '[server-secret-key]' - client_access_key = '[client-access-key]' - client_secret_key = '[client-secret-key]' + server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] + server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] + client_access_key = os.environ["VWS_CLIENT_ACCESS_KEY"] + client_secret_key = os.environ["VWS_CLIENT_SECRET_KEY"] vws_client = VWS( server_access_key=server_access_key, server_secret_key=server_secret_key, ) + cloud_reco_client = CloudRecoService( client_access_key=client_access_key, client_secret_key=client_secret_key, ) - name = 'my_image_name' - - image = pathlib.Path('high_quality_image.jpg') - with image.open(mode='rb') as my_image_file: - target_id = vws_client.add_target( - name=name, - width=1, - image=my_image_file, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=my_image_file) - assert matching_targets[0].target_id == target_id - a = 1 + name = "my_image_name_" + uuid.uuid4().hex + + image = pathlib.Path("high_quality_image.jpg") + with image.open(mode="rb") as my_image_file: + target_id = vws_client.add_target( + name=name, + width=1, + image=my_image_file, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) -.. invisible-code-block: python + with image.open(mode="rb") as my_image_file: + matching_targets = cloud_reco_client.query(image=my_image_file) - new_image = pathlib.Path('high_quality_image.jpg') - new_image.unlink() - stack.close() + assert matching_targets[0].target_id == target_id 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 + """Add a target to VWS and then query it.""" - from mock_vws.database import VuforiaDatabase - from mock_vws import MockVWS - from vws import CloudRecoService, VWS + import pathlib - with MockVWS() as mock: - database = VuforiaDatabase() - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) + from mock_vws import MockVWS + from mock_vws.database import VuforiaDatabase + from vws import VWS, CloudRecoService - image = pathlib.Path('high_quality_image.jpg') - with image.open(mode='rb') as my_image_file: - target_id = vws_client.add_target( - name="example_image_name", - width=1, - image=my_image_file, - application_metadata=None, - active_flag=True, - ) + with MockVWS() as mock: + database = VuforiaDatabase() + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) -.. invisible-code-block: python + image = pathlib.Path("high_quality_image.jpg") + with image.open(mode="rb") as my_image_file: + target_id = vws_client.add_target( + name="example_image_name", + width=1, + image=my_image_file, + application_metadata=None, + active_flag=True, + ) - new_image = pathlib.Path('high_quality_image.jpg') - new_image.unlink() + vws_client.wait_for_target_processed(target_id=target_id) + matching_targets = cloud_reco_client.query(image=my_image_file) + + assert matching_targets[0].target_id == target_id There are some differences between the mock and the real Vuforia. -See https://vws-python-mock.readthedocs.io/en/latest/differences-to-vws.html for details. +See https://vws-python.github.io/vws-python-mock/differences-to-vws for details. .. _VWS Python Mock: https://github.com/VWS-Python/vws-python-mock - Reference --------- diff --git a/docs/source/release-process.rst b/docs/source/release-process.rst index d947f9fd3..db1744feb 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..f69231fbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,106 +1,288 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = [ + "setuptools", + "setuptools-scm>=8.1.0", + "wheel", +] + +[project] +name = "vws-python" +description = "Interact with the Vuforia Web Services (VWS) API." +readme = { file = "README.rst", content-type = "text/x-rst" } +keywords = [ + "client", + "vuforia", + "vws", +] +license = { file = "LICENSE" } +authors = [ + { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, +] +requires-python = ">=3.13" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.13", +] +dynamic = [ + "version", +] +dependencies = [ + "beartype>=0.18.5", + "requests>=2.32.3", + "urllib3>=2.2.3", + "vws-auth-tools>=2024.7.12", +] +optional-dependencies.dev = [ + "actionlint-py==1.7.7.23", + "check-manifest==0.50", + "deptry==0.23.0", + "doc8==1.1.2", + "doccmd==2025.3.6", + "docformatter==1.7.5", + "freezegun==1.5.1", + "furo==2024.8.6", + "interrogate==1.7.0", + "mypy[faster-cache]==1.15.0", + "mypy-strict-kwargs==2024.12.25", + "pre-commit==4.1.0", + "pydocstyle==6.3", + "pyenchant==3.3.0rc1", + "pygments==2.19.1", + "pylint==3.3.4", + "pylint-per-file-ignores==1.4.0", + "pyproject-fmt==2.5.1", + "pyright==1.1.396", + "pyroma==4.2", + "pytest==8.3.5", + "pytest-cov==6.0.0", + "pyyaml==6.0.2", + "ruff==0.9.10", + # We add shellcheck-py not only for shell scripts and shell code blocks, + # but also because having it installed means that ``actionlint-py`` will + # use it to lint shell commands in GitHub workflow files. + "shellcheck-py==0.10.0.1", + "shfmt-py==3.7.0.1", + "sphinx==8.2.3", + "sphinx-copybutton==0.5.2", + "sphinx-lint==1.0.0", + "sphinx-pyproject==0.3.0", + "sphinx-substitution-extensions==2025.3.3", + "sphinxcontrib-spelling==8.0.1", + "sybil==9.1.0", + "types-requests==2.32.0.20250306", + "vulture==2.14", + "vws-python-mock==2025.3.10", + "vws-test-fixtures==2023.3.5", + "yamlfix==1.17.0", +] +optional-dependencies.release = [ "check-wheel-contents==0.6.1" ] +urls.Documentation = "https://vws-python.github.io/vws-python/" +urls.Source = "https://github.com/VWS-Python/vws-python" + +[tool.setuptools] +zip-safe = false + +[tool.setuptools.packages.find] +where = [ + "src", +] + +[tool.setuptools.package-data] +vws = [ + "py.typed", +] + +[tool.distutils.bdist_wheel] +universal = true + +[tool.setuptools_scm] + +# This keeps the start of the version the same as the last release. +# This is useful for our documentation to include e.g. binary links +# to the latest released binary. +# +# Code to match this is in ``conf.py``. +version_scheme = "post-release" + +[tool.ruff] +line-length = 79 + +lint.select = [ + "ALL", +] +lint.ignore = [ + # Ruff warns that this conflicts with the formatter. + "COM812", + # Allow our chosen docstring line-style - no one-line summary. + "D200", + "D205", + "D212", + # Ruff warns that this conflicts with the formatter. + "ISC001", + # Ignore "too-many-*" errors as they seem to get in the way more than + # helping. + "PLR0913", +] + +lint.per-file-ignores."doccmd_*.py" = [ + # Allow asserts in docs. + "S101", +] + +lint.per-file-ignores."docs/source/*.py" = [ + # Allow asserts in docs. + "S101", +] + +lint.per-file-ignores."tests/*.py" = [ + # Allow asserts in tests. + "S101", +] + +# Do not automatically remove commented out code. +# We comment out code during development, and with VSCode auto-save, this code +# is sometimes annoyingly removed. +lint.unfixable = [ + "ERA001", +] +lint.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. - 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.pylint.'MASTER'] + +# Pickle collected data for later comparisons. +persistent = true + +# Use multiple processes to speed up Pylint. +jobs = 0 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +# See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. +# and we also add `pylint_per_file_ignores` to allow per-file ignores. +# We do not use the plugins: +# - pylint.extensions.code_style +# - pylint.extensions.magic_value +# - pylint.extensions.while_used +# as they seemed to get in the way. +load-plugins = [ + "pylint_per_file_ignores", + 'pylint.extensions.bad_builtin', + 'pylint.extensions.comparison_placement', + 'pylint.extensions.consider_refactoring_into_while_condition', + 'pylint.extensions.docparams', + 'pylint.extensions.dunder', + 'pylint.extensions.eq_without_hash', + 'pylint.extensions.for_any_all', + 'pylint.extensions.mccabe', + 'pylint.extensions.no_self_use', + 'pylint.extensions.overlapping_exceptions', + 'pylint.extensions.private_import', + 'pylint.extensions.redefined_loop_name', + 'pylint.extensions.redefined_variable_type', + 'pylint.extensions.set_membership', + 'pylint.extensions.typing', +] -[tool.coverage.run] +# 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', +] -branch = true +# 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.coverage.report] -exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"] +# We ignore invalid names because: +# - We want to use generated module names, which may not be valid, but are never seen. +# - We want to use global variables in documentation, which may not be uppercase. +# - conf.py is a Sphinx configuration file which requires lowercase global variable names. +per-file-ignores = [ + "docs/:invalid-name", + "doccmd_README_rst.*.py:invalid-name", +] -[tool.pytest.ini_options] +[tool.pylint.'FORMAT'] -xfail_strict = true -log_cli = true +# 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.docformatter] +make-summary-multi-line = true [tool.check-manifest] ignore = [ + ".checkmake-config.ini", + ".yamlfmt", "*.enc", - "readthedocs.yaml", + ".pre-commit-config.yaml", "CHANGELOG.rst", "CODE_OF_CONDUCT.rst", "CONTRIBUTING.rst", @@ -121,153 +303,106 @@ ignore = [ "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.deptry] +pep621_dev_dependency_groups = [ + "dev", + "release", ] -[tool.mypy] +[tool.pyproject-fmt] +indent = 4 +keep_full_version = true +max_supported_python = "3.13" -strict = true +[tool.pytest.ini_options] -[build-system] -build-backend = "setuptools.build_meta" -requires = [ - "pip", - "setuptools", - "setuptools_scm[toml]>=7.1", - "wheel", -] +xfail_strict = true +log_cli = true -[tool.distutils.bdist_wheel] -universal = true +[tool.coverage.run] -[tool.ruff] -select = ["ALL"] -line-length = 79 +branch = true -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", +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:", ] -# 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.mypy] -[tool.ruff.per-file-ignores] -"tests/test_*.py" = [ - # Do not require tests to have a one-line summary. - "D205", +strict = true +files = [ "." ] +exclude = [ "build" ] +follow_untyped_imports = true +plugins = [ + "mypy_strict_kwargs", ] -[project] -name = "vws-python" -description = "Interact with the Vuforia Web Services (VWS) API." -readme = { file = "README.rst", content-type = "text/x-rst"} -keywords = [ - "client", - "vuforia", - "vws", -] -license = { file = "LICENSE" } -authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com"} ] -requires-python = ">=3.12" -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Environment :: Web Environment", - "License :: OSI Approved :: MIT License", - "Operating System :: POSIX", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.12", -] -dynamic = [ - "version", -] -dependencies = [ - "requests", - "urllib3", - "VWS-Auth-Tools", -] -[project.optional-dependencies] -dev = [ - "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", - "pydocstyle==6.3", - "pyenchant==3.2.2", - "Pygments==2.17.2", - "pylint==3.0.3", - "pyproject-fmt==1.5.3", - "pyright==1.1.343", - "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", - "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", -] -[project.urls] -Documentation = "https://vws-python.readthedocs.io/en/latest/" -Source = "https://github.com/VWS-Python/vws-python" +[tool.pyright] -[tool.setuptools] -zip-safe = false +enableTypeIgnoreComments = false +reportUnnecessaryTypeIgnoreComment = true +typeCheckingMode = "strict" -[tool.setuptools.packages.find] -where = ["src"] +[tool.interrogate] +fail-under = 100 +omit-covered-files = true +verbose = 2 -[tool.setuptools.package-data] -vws = ["py.typed"] +[tool.doc8] -[tool.setuptools_scm] +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.pyright] +[tool.vulture] +# Ideally we would limit the paths to the source code where we want to ignore names, +# but Vulture does not enable this. +ignore_names = [ + # pytest configuration + "pytest_collect_file", + "pytest_collection_modifyitems", + "pytest_plugins", + # pytest fixtures - we name fixtures like this for this purpose + "fixture_*", + # Sphinx + "autoclass_content", + "autoclass_content", + "autodoc_member_order", + "copybutton_exclude", + "extensions", + "html_show_copyright", + "html_show_sourcelink", + "html_show_sphinx", + "html_theme", + "html_theme_options", + "html_title", + "htmlhelp_basename", + "intersphinx_mapping", + "language", + "linkcheck_ignore", + "linkcheck_retries", + "master_doc", + "nitpicky", + "nitpick_ignore", + "project_copyright", + "pygments_style", + "rst_prolog", + "source_suffix", + "spelling_word_list_filename", + "templates_path", + "warning_is_error", +] -typeCheckingMode = "strict" +# Duplicate some of .gitignore +exclude = [ ".venv" ] + +[tool.yamlfix] +section_whitelines = 1 +whitelines = 1 diff --git a/readthedocs.yaml b/readthedocs.yaml deleted file mode 100644 index 88e40e66a..000000000 --- a/readthedocs.yaml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 - -build: - os: ubuntu-20.04 - tools: - python: "3.12" - -python: - install: - - method: pip - path: . - extra_requirements: - - dev - -sphinx: - builder: html - fail_on_warning: true diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index a11d546a6..ce782a4af 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 @@ -89,6 +90,7 @@ rgb str timestamp todo +traceback travis txt unmocked diff --git a/src/vws/__init__.py b/src/vws/__init__.py index 9e4633af8..42788b92d 100644 --- a/src/vws/__init__.py +++ b/src/vws/__init__.py @@ -6,6 +6,6 @@ from .vws import VWS __all__ = [ - "CloudRecoService", "VWS", + "CloudRecoService", ] diff --git a/src/vws/exceptions/base_exceptions.py b/src/vws/exceptions/base_exceptions.py index 730d1a2a5..943323bfd 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 vws.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,12 @@ def response(self) -> Response: return self._response -class VWSException(Exception): - """ - Base class for Vuforia Web Services errors. +@beartype +class VWSError(Exception): + """Base class for Vuforia Web Services errors. These errors are defined at - https://library.vuforia.com/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..694e98c30 100644 --- a/src/vws/exceptions/custom_exceptions.py +++ b/src/vws/exceptions/custom_exceptions.py @@ -1,19 +1,18 @@ """ 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 vws.response import Response -class OopsAnErrorOccurredPossiblyBadName(Exception): +@beartype +class RequestEntityTooLargeError(Exception): """ - Exception raised when VWS returns an HTML page which says "Oops, an error - occurred". - - This has been seen to happen when the given name includes a bad character. + Exception raised when the given image is too large. """ def __init__(self, response: Response) -> None: @@ -32,13 +31,30 @@ def response(self) -> Response: return self._response -class RequestEntityTooLarge(Exception): +@beartype +class TargetProcessingTimeoutError(Exception): """ - Exception raised when the given image is too large. + Exception raised when waiting for a target to be processed times out. """ -class TargetProcessingTimeout(Exception): +@beartype +class ServerError(Exception): # pragma: no cover """ - Exception raised when waiting for a target to be processed times out. + 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/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 9ff990255..482223766 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,29 @@ 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'. + 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 +54,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 +74,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 +90,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 +99,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 +108,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 +152,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) - return str(request_json["name"]) + response_body = self.response.request_body or b"" + request_json = json.loads(s=response_body) + return str(object=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 +177,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..37682e08a 100644 --- a/src/vws/include_target_data.py +++ b/src/vws/include_target_data.py @@ -2,10 +2,13 @@ Tools for managing ``CloudRecoService.query``'s ``include_target_data``. """ +from enum import StrEnum, auto, unique -from enum import StrEnum, auto +from beartype import beartype +@beartype +@unique 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..96122aa14 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -2,32 +2,41 @@ 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.include_target_data import CloudRecoIncludeTargetData from vws.reports import QueryResult, TargetData +from vws.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() @@ -35,6 +44,7 @@ def _get_image_data(image: BinaryIO) -> bytes: return image_data +@beartype class CloudRecoService: """ An interface to the Vuforia Cloud Recognition Web APIs. @@ -58,17 +68,16 @@ def __init__( def query( self, - image: BinaryIO, + image: _ImageType, max_num_results: int = 1, include_target_data: CloudRecoIncludeTargetData = ( CloudRecoIncludeTargetData.TOP ), ) -> list[QueryResult]: - """ - Use the Vuforia Web Query API to make an Image Recognition Query. + """Use the Vuforia Web Query API to make an Image Recognition Query. See - https://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 +90,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 +122,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 +141,46 @@ 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, + tell_position=requests_response.raw.tell(), ) 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 +188,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..f6a77133c 100644 --- a/src/vws/reports.py +++ b/src/vws/reports.py @@ -4,16 +4,18 @@ import datetime from dataclasses import dataclass -from enum import Enum +from enum import Enum, unique +from beartype import BeartypeConf, beartype -@dataclass + +@beartype +@dataclass(frozen=True) class DatabaseSummaryReport: - """ - A database summary report. + """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 +32,13 @@ class DatabaseSummaryReport: total_recos: int +@beartype +@unique 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,13 @@ class TargetStatuses(Enum): FAILED = "failed" -@dataclass +@beartype +@dataclass(frozen=True) class TargetSummaryReport: - """ - A target summary report. + """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 +66,13 @@ class TargetSummaryReport: previous_month_recos: int -@dataclass +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +@dataclass(frozen=True) class TargetRecord: - """ - A target record. + """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,7 +83,8 @@ class TargetRecord: reco_rating: str -@dataclass +@beartype +@dataclass(frozen=True) class TargetData: """ The target data optionally included with a query match. @@ -90,26 +95,26 @@ class TargetData: target_timestamp: datetime.datetime -@dataclass +@beartype +@dataclass(frozen=True) class QueryResult: - """ - One query match result. + """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 -@dataclass +@beartype +@dataclass(frozen=True) class TargetStatusAndRecord: - """ - The target status and a target record. + """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/response.py b/src/vws/response.py new file mode 100644 index 000000000..269f2e23f --- /dev/null +++ b/src/vws/response.py @@ -0,0 +1,22 @@ +""" +Responses for requests to VWS and VWQ. +""" + +from dataclasses import dataclass + +from beartype import beartype + + +@dataclass(frozen=True) +@beartype +class Response: + """ + A response from a request. + """ + + text: str + url: str + status_code: int + headers: dict[str, str] + request_body: bytes | str | None + tell_position: int diff --git a/src/vws/vws.py b/src/vws/vws.py index 267f88411..5dd7503db 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -2,42 +2,41 @@ 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, + 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, @@ -46,12 +45,16 @@ TargetStatuses, TargetSummaryReport, ) +from vws.response import Response -if TYPE_CHECKING: - import io +_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,25 +62,27 @@ def _get_image_data(image: BinaryIO) -> bytes: return image_data +@beartype def _target_api_request( + *, + content_type: str, server_access_key: str, server_secret_key: str, method: str, - content: bytes, + data: bytes, request_path: str, base_vws_url: str, ) -> Response: - """ - Make a request to the Vuforia Target API. + """Make a request to the Vuforia Target API. This uses `requests` to make a request against https://vws.vuforia.com. - The content type of the request will be `application/json`. Args: + content_type: The content type of the request. server_access_key: A VWS server access key. server_secret_key: A VWS server secret key. method: The HTTP method which will be used in the request. - 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. @@ -86,13 +91,12 @@ def _target_api_request( The response to the request made by `requests`. """ date_string = rfc_1123_date() - content_type = "application/json" signature_string = authorization_header( access_key=server_access_key, secret_key=server_secret_key, method=method, - content=content, + content=data, content_type=content_type, date=date_string, request_path=request_path, @@ -106,16 +110,26 @@ 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, + tell_position=requests_response.raw.tell(), ) +@beartype(conf=BeartypeConf(is_pep484_tower=True)) class VWS: """ An interface to Vuforia Web Services APIs. @@ -137,79 +151,83 @@ 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, + content_type: str, ) -> Response: - """ - Make a request to the Vuforia Target API. + """Make a request to the Vuforia Target API. - This uses `requests` to make a request against https://vws.vuforia.com. - The content type of the request will be `application/json`. + This uses `requests` to make a request against Vuforia. 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. + content_type: The content type of the request. 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 - 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( + content_type=content_type, server_access_key=self._server_access_key, server_secret_key=self._server_secret_key, method=method, - content=content, + data=data, request_path=request_path, base_vws_url=self._base_vws_url, ) - if "Oops, an error occurred" in response.text: - raise OopsAnErrorOccurredPossiblyBadName(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) + + 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 == 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,16 +236,15 @@ def add_target( self, name: str, width: float, - image: BinaryIO, + image: _ImageType, application_metadata: str | None, *, active_flag: bool, ) -> str: - """ - Add a target to a Vuforia Web Services database. + """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 +261,35 @@ 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: - Vuforia returns an HTML page with the text "Oops, an error - occurred". This has been seen to happen when the given name - includes a bad character. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. This has been seen to happen when the + given name includes a bad character. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ image_data = _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 +299,24 @@ 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", + content_type="application/json", ) - return str(response.json()["target_id"]) + return str(object=json.loads(s=response.text)["target_id"]) def get_target_record(self, target_id: str) -> TargetStatusAndRecord: - """ - Get a given target's target record from the Target Management System. + """Get a given target's target record from the Target Management + System. See - https://library.vuforia.com/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 +325,30 @@ 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", + content_type="application/json", ) - 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"], @@ -344,8 +369,7 @@ def wait_for_target_processed( seconds_between_requests: float = 0.2, timeout_seconds: float = 60 * 5, ) -> None: - """ - Wait up to five minutes (arbitrary) for a target to get past the + """Wait up to five minutes (arbitrary) for a target to get past the processing stage. Args: @@ -359,18 +383,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,44 +408,47 @@ 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) def list_targets(self) -> list[str]: - """ - List target IDs. + """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", + content_type="application/json", ) - return list(response.json()["results"]) + return list(json.loads(s=response.text)["results"]) def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: - """ - Get a summary report for a target. + """Get a summary report for a target. See - https://library.vuforia.com/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 +457,31 @@ 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", + content_type="application/json", ) - 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"]), @@ -457,32 +493,36 @@ def get_target_summary_report(self, target_id: str) -> TargetSummaryReport: ) def get_database_summary_report(self) -> DatabaseSummaryReport: - """ - Get a summary report for the database. + """Get a summary report for the database. See - https://library.vuforia.com/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", + content_type="application/json", ) - 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"], @@ -499,41 +539,44 @@ def get_database_summary_report(self) -> DatabaseSummaryReport: ) def delete_target(self, target_id: str) -> None: - """ - Delete a given target. + """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", + content_type="application/json", ) def get_duplicate_targets(self, target_id: str) -> list[str]: - """ - Get targets which may be considered duplicates of a given target. + """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 +585,50 @@ def get_duplicate_targets(self, target_id: str) -> list[str]: The target IDs of duplicate targets. Raises: - ~vws.exceptions.vws_exceptions.AuthenticationFailure: The secret - key is not correct. - ~vws.exceptions.vws_exceptions.Fail: There was an error with the - request. For example, the given access key does not match a + ~vws.exceptions.vws_exceptions.AuthenticationFailureError: The + secret key is not correct. + ~vws.exceptions.vws_exceptions.FailError: There was an error with + the request. For example, the given access key does not match a known database. - ~vws.exceptions.vws_exceptions.UnknownTarget: The given target ID - does not match a target in the database. - ~vws.exceptions.vws_exceptions.ProjectInactive: The project is + ~vws.exceptions.vws_exceptions.UnknownTargetError: The given target + ID does not match a target in the database. + ~vws.exceptions.vws_exceptions.ProjectInactiveError: The project is inactive. - ~vws.exceptions.vws_exceptions.RequestTimeTooSkewed: There is an - error with the time sent to Vuforia. + ~vws.exceptions.vws_exceptions.RequestTimeTooSkewedError: There is + an error with the time sent to Vuforia. + ~vws.exceptions.custom_exceptions.ServerError: There is an error + with Vuforia's servers. + ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is + rate limiting access. """ - response = self._make_request( - method="GET", - content=b"", + response = self.make_request( + method=HTTPMethod.GET, + data=b"", request_path=f"/duplicates/{target_id}", expected_result_code="Success", + content_type="application/json", ) - return list(response.json()["similar_targets"]) + return list(json.loads(s=response.text)["similar_targets"]) def update_target( self, + *, target_id: str, name: 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 +641,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 +675,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 +686,12 @@ 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", + content_type="application/json", ) diff --git a/tests/__init__.py b/tests/__init__.py index 3502d86d5..c7e38a862 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,3 @@ -"""Tests for ``vws``.""" +""" +Tests for ``vws``. +""" diff --git a/tests/conftest.py b/tests/conftest.py index ac8181bd7..74c6fc56d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,33 +2,31 @@ 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") -def mock_database() -> Generator[VuforiaDatabase, None, None]: +def fixture_mock_database() -> Generator[VuforiaDatabase]: """ 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,32 @@ def cloud_reco_client(_mock_database: VuforiaDatabase) -> CloudRecoService: ) -@pytest.fixture() -def image_file( +@pytest.fixture(name="image_file", params=["r+b", "rb"]) +def fixture_image_file( high_quality_image: io.BytesIO, tmp_path: Path, -) -> Generator[io.BufferedRandom, None, None]: - """An image file object.""" + request: pytest.FixtureRequest, +) -> Generator[BinaryIO]: + """ + 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: - """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 + high_quality_image: io.BytesIO, + image_file: BinaryIO, +) -> io.BytesIO | BinaryIO: + """ + An image in any of the types that the API accepts. + """ + if request.param == "high_quality_image": + return high_quality_image + return image_file diff --git a/tests/test_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py index 7e7261599..3515b0d22 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(expected_exception=MaxNumResultsOutOfRangeError) as exc: cloud_reco_client.query( image=high_quality_image, max_num_results=51, @@ -47,37 +43,43 @@ def test_too_many_max_results( "Integer out of range (51) in form data part 'max_result'. " "Accepted range is from 1 to 50 (inclusive)." ) - assert str(exc.value) == exc.value.response.text == expected_value + assert str(object=exc.value) == exc.value.response.text == expected_value def test_image_too_large( cloud_reco_client: CloudRecoService, - png_too_large: io.BytesIO, + png_too_large: io.BytesIO | io.BufferedRandom, ) -> None: """ A ``RequestEntityTooLarge`` exception is raised if an image which is too large is given. """ - with pytest.raises(RequestEntityTooLarge): + with pytest.raises(expected_exception=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,17 @@ 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( + expected_exception=AuthenticationFailureError + ) as exc: cloud_reco_client.query(image=high_quality_image) assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED -def test_inactive_project(high_quality_image: io.BytesIO) -> None: +def test_inactive_project( + high_quality_image: io.BytesIO, +) -> None: """ An ``InactiveProject`` exception is raised when querying an inactive database. @@ -109,7 +115,11 @@ 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(expected_exception=InactiveProjectError) as exc: cloud_reco_client.query(image=high_quality_image) - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + response = exc.value.response + assert response.status_code == HTTPStatus.FORBIDDEN + # We need one test which checks tell position + # and so we choose this one almost at random. + assert response.tell_position != 0 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..e16eff555 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: """ @@ -33,11 +30,14 @@ class TestAddTarget: """ @staticmethod - @pytest.mark.parametrize("application_metadata", [None, b"a"]) - @pytest.mark.parametrize("active_flag", [True, False]) + @pytest.mark.parametrize( + argnames="application_metadata", + argvalues=[None, b"a"], + ) + @pytest.mark.parametrize(argnames="active_flag", argvalues=[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 +51,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,10 +81,9 @@ 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. + """No exception is raised when adding two targets with different names. This demonstrates that the image seek position is not changed. """ @@ -104,7 +103,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 +135,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 +165,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 +192,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, @@ -209,20 +208,35 @@ def test_get_target_summary_report( application_metadata=None, ) - result = vws_client.get_target_summary_report(target_id=target_id) + report = vws_client.get_target_summary_report(target_id=target_id) expected_report = TargetSummaryReport( status=TargetStatuses.SUCCESS, - database_name=result.database_name, + database_name=report.database_name, target_name=target_name, - upload_date=datetime.date(2018, 4, 25), + upload_date=datetime.date(year=2018, month=4, day=25), active_flag=True, - tracking_rating=result.tracking_rating, + tracking_rating=report.tracking_rating, total_recos=0, current_month_recos=0, previous_month_recos=0, ) - assert result == expected_report + + assert report.status == expected_report.status + assert report.database_name == expected_report.database_name + assert report.target_name == expected_report.target_name + assert report.upload_date == expected_report.upload_date + assert report.active_flag == expected_report.active_flag + assert report.tracking_rating == expected_report.tracking_rating + assert report.total_recos == expected_report.total_recos + assert ( + report.current_month_recos == expected_report.current_month_recos + ) + assert ( + report.previous_month_recos == expected_report.previous_month_recos + ) + + assert report == expected_report class TestGetDatabaseSummaryReport: @@ -251,6 +265,24 @@ def test_get_target(vws_client: VWS) -> None: target_quota=1000, total_recos=0, ) + + assert report.active_images == expected_report.active_images + assert ( + report.current_month_recos == expected_report.current_month_recos + ) + assert report.failed_images == expected_report.failed_images + assert report.inactive_images == expected_report.inactive_images + assert report.name == expected_report.name + assert ( + report.previous_month_recos == expected_report.previous_month_recos + ) + assert report.processing_images == expected_report.processing_images + assert report.reco_threshold == expected_report.reco_threshold + assert report.request_quota == expected_report.request_quota + assert report.request_usage == expected_report.request_usage + assert report.target_quota == expected_report.target_quota + assert report.total_recos == expected_report.total_recos + assert report == expected_report @@ -262,7 +294,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``. @@ -286,8 +318,48 @@ def test_get_target_record( ) assert result.target_record == expected_target_record + + assert ( + result.target_record.target_id == expected_target_record.target_id + ) + assert ( + result.target_record.active_flag + == expected_target_record.active_flag + ) + assert result.target_record.name == expected_target_record.name + assert result.target_record.width == expected_target_record.width + assert ( + result.target_record.tracking_rating + == expected_target_record.tracking_rating + ) + assert ( + result.target_record.reco_rating + == expected_target_record.reco_rating + ) + assert result.status == TargetStatuses.PROCESSING + @staticmethod + def test_get_failed( + vws_client: VWS, + image_file_failed_state: io.BytesIO, + ) -> None: + """ + Check that the report works with a failed target. + """ + target_id = vws_client.add_target( + name="x", + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + result = vws_client.get_target_record(target_id=target_id) + + assert result.status == TargetStatuses.FAILED + class TestWaitForTargetProcessed: """ @@ -297,7 +369,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 +389,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 +441,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 +492,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 +514,9 @@ 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( + expected_exception=TargetProcessingTimeoutError + ): vws_client.wait_for_target_processed( target_id=target_id, timeout_seconds=0.1, @@ -464,7 +538,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 +572,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 +580,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 +597,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 +633,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..86da66040 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 ( + ServerError, +) 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(expected_exception=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(expected_exception=UnknownTargetError) as exc: vws_client.delete_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.NOT_FOUND assert exc.value.target_id == target_id @@ -68,12 +71,14 @@ def test_invalid_given_id(vws_client: VWS) -> None: def test_add_bad_name(vws_client: VWS, high_quality_image: io.BytesIO) -> None: """ - When a name with a bad character is given, an - ``OopsAnErrorOccurredPossiblyBadName`` exception is raised. + When a name with a bad character is given, a ``ServerError`` exception is + raised. """ max_char_value = 65535 bad_name = chr(max_char_value + 1) - with pytest.raises(OopsAnErrorOccurredPossiblyBadName) as exc: + with pytest.raises( + expected_exception=ServerError, + ) as exc: vws_client.add_target( name=bad_name, width=1, @@ -102,7 +107,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(expected_exception=FailError) as exc: vws_client.add_target( name="x", width=1, @@ -118,8 +123,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(expected_exception=BadImageError) as exc: vws_client.add_target( name="x", width=1, @@ -136,8 +141,8 @@ def test_target_name_exist( high_quality_image: io.BytesIO, ) -> None: """ - A ``TargetNameExist`` exception is raised after adding two targets with - the same name. + A ``TargetNameExist`` exception is raised after adding two targets with the + same name. """ vws_client.add_target( name="x", @@ -146,7 +151,7 @@ def test_target_name_exist( active_flag=True, application_metadata=None, ) - with pytest.raises(TargetNameExist) as exc: + with pytest.raises(expected_exception=TargetNameExistError) as exc: vws_client.add_target( name="x", width=1, @@ -159,10 +164,12 @@ 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. + A ``ProjectInactive`` exception is raised if adding a target to an inactive + database. """ database = VuforiaDatabase(state=States.PROJECT_INACTIVE) with MockVWS() as mock: @@ -172,7 +179,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(expected_exception=ProjectInactiveError) as exc: vws_client.add_target( name="x", width=1, @@ -200,7 +207,7 @@ def test_target_status_processing( application_metadata=None, ) - with pytest.raises(TargetStatusProcessing) as exc: + with pytest.raises(expected_exception=TargetStatusProcessingError) as exc: vws_client.delete_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN @@ -215,7 +222,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(expected_exception=MetadataTooLargeError) as exc: vws_client.add_target( name="x", width=1, @@ -255,14 +262,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(expected_exception=RequestTimeTooSkewedError) as exc, ): vws_client.get_target_record(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN -def test_authentication_failure(high_quality_image: io.BytesIO) -> None: +def test_authentication_failure( + high_quality_image: io.BytesIO, +) -> None: """ An ``AuthenticationFailure`` exception is raised when the server access key exists but the server secret key is incorrect, or when a client key is @@ -278,7 +287,9 @@ 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( + expected_exception=AuthenticationFailureError + ) as exc: vws_client.add_target( name="x", width=1, @@ -306,7 +317,7 @@ def test_target_status_not_success( application_metadata=None, ) - with pytest.raises(TargetStatusNotSuccess) as exc: + with pytest.raises(expected_exception=TargetStatusNotSuccessError) as exc: vws_client.update_target(target_id=target_id) assert exc.value.response.status_code == HTTPStatus.FORBIDDEN @@ -318,25 +329,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 +357,7 @@ def test_base_exception( """ ``VWSException``s has a response property. """ - with pytest.raises(VWSException) as exc: + with pytest.raises(expected_exception=VWSError) as exc: vws_client.get_target_record(target_id="a") assert exc.value.response.status_code == HTTPStatus.NOT_FOUND