From c682fcc5844dbffc0e8b14814dc33602b18ce839 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 4 Aug 2026 11:58:41 +0100 Subject: [PATCH 1/3] Use VWS Python Mock 2026.8.4 --- .github/workflows/ci.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish-site.yml | 2 +- README.rst | 2 +- newsfragments/822.change.rst | 1 + pyproject.toml | 5 +- src/vws/exceptions/vws_exceptions.py | 12 ++--- tests/test_async_vws_exceptions.py | 75 ++++++++++++++++++++++++++++ tests/test_vws_exceptions.py | 66 ++++++++++++++++++++++-- 9 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 newsfragments/822.change.rst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a89a87c1..21f12ba8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: build: strategy: matrix: - python-version: ['3.13', '3.14'] + python-version: ['3.14'] platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 091929e2a..3a0e1e1d9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: build: strategy: matrix: - python-version: ['3.13'] + python-version: ['3.14'] platform: [ubuntu-latest, windows-latest] hook-stage: [pre-commit, pre-push, manual] diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml index 8f5a347a4..fceb50805 100644 --- a/.github/workflows/publish-site.yml +++ b/.github/workflows/publish-site.yml @@ -22,7 +22,7 @@ jobs: with: documentation_path: docs/source pyproject_extras: dev - python_version: '3.13' + python_version: '3.14' sphinx_build_options: -W cache: true publish: ${{ github.ref_name == 'main' }} diff --git a/README.rst b/README.rst index dea6bb93d..b11993035 100644 --- a/README.rst +++ b/README.rst @@ -73,4 +73,4 @@ See the `full documentation `__. :target: https://github.com/VWS-Python/vws-python/actions .. |PyPI| image:: https://badge.fury.io/py/VWS-Python.svg :target: https://badge.fury.io/py/VWS-Python -.. |minimum-python-version| replace:: 3.13 +.. |minimum-python-version| replace:: 3.14 diff --git a/newsfragments/822.change.rst b/newsfragments/822.change.rst new file mode 100644 index 000000000..18195db79 --- /dev/null +++ b/newsfragments/822.change.rst @@ -0,0 +1 @@ +Drop Python 3.13 support, update VWS Python Mock, and test quota and project-state error responses against the mock. diff --git a/pyproject.toml b/pyproject.toml index 35b19ff6c..05acb996d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,14 +18,13 @@ license = "MIT" authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, ] -requires-python = ">=3.13" +requires-python = ">=3.14" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] dynamic = [ @@ -90,7 +89,7 @@ optional-dependencies.dev = [ "ty==0.0.65", "types-requests==2.33.0.20260712", "vulture==2.16", - "vws-python-mock==2026.2.22.3", + "vws-python-mock==2026.8.4", "vws-test-fixtures==2023.3.5", "yamlfix==1.19.1", "zizmor==1.28.0", diff --git a/src/vws/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 3e7e12265..3749b5619 100644 --- a/src/vws/exceptions/vws_exceptions.py +++ b/src/vws/exceptions/vws_exceptions.py @@ -65,9 +65,8 @@ class AuthenticationFailureError(VWSError): """ -# See https://github.com/VWS-Python/vws-python/issues/822. @beartype -class RequestQuotaReachedError(VWSError): # pragma: no cover +class RequestQuotaReachedError(VWSError): """Exception raised when Vuforia returns a response with a result code 'RequestQuotaReached'. """ @@ -93,25 +92,22 @@ class DateRangeError(VWSError): # pragma: no cover """ -# This is not simulated by the mock. @beartype -class TargetQuotaReachedError(VWSError): # pragma: no cover +class TargetQuotaReachedError(VWSError): """Exception raised when Vuforia returns a response with a result code 'TargetQuotaReached'. """ -# This is not simulated by the mock. @beartype -class ProjectSuspendedError(VWSError): # pragma: no cover +class ProjectSuspendedError(VWSError): """Exception raised when Vuforia returns a response with a result code 'ProjectSuspended'. """ -# This is not simulated by the mock. @beartype -class ProjectHasNoAPIAccessError(VWSError): # pragma: no cover +class ProjectHasNoAPIAccessError(VWSError): """Exception raised when Vuforia returns a response with a result code 'ProjectHasNoAPIAccess'. """ diff --git a/tests/test_async_vws_exceptions.py b/tests/test_async_vws_exceptions.py index cd35854f1..daa57423a 100644 --- a/tests/test_async_vws_exceptions.py +++ b/tests/test_async_vws_exceptions.py @@ -10,6 +10,7 @@ from mock_vws.states import States from vws import AsyncVuMarkService, AsyncVWS +from vws.exceptions.base_exceptions import VWSError # noqa: TC001 from vws.exceptions.custom_exceptions import ( ServerError, ) @@ -20,8 +21,12 @@ ImageTooLargeError, InvalidInstanceIdError, MetadataTooLargeError, + ProjectHasNoAPIAccessError, ProjectInactiveError, + ProjectSuspendedError, + RequestQuotaReachedError, TargetNameExistError, + TargetQuotaReachedError, TargetStatusProcessingError, UnknownTargetError, ) @@ -94,6 +99,76 @@ async def test_add_bad_name( assert exc.value.response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR +@pytest.mark.asyncio +async def test_request_quota_reached() -> None: + """A ``RequestQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(request_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=RequestQuotaReachedError) as exc: + await async_vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.asyncio +async def test_target_quota_reached( + high_quality_image: io.BytesIO, +) -> None: + """A ``TargetQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(target_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=TargetQuotaReachedError) as exc: + await async_vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames=("state", "expected_exception"), + argvalues=[ + (States.PROJECT_SUSPENDED, ProjectSuspendedError), + (States.PROJECT_HAS_NO_API_ACCESS, ProjectHasNoAPIAccessError), + ], +) +async def test_project_state_error( + *, + state: States, + expected_exception: type[VWSError], +) -> None: + """Configured project states raise their matching exceptions.""" + database = CloudDatabase(state=state) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=expected_exception) as exc: + await async_vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + @pytest.mark.asyncio async def test_fail(high_quality_image: io.BytesIO) -> None: """A ``Fail`` exception is raised when the server access key diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 92b33d539..f05436e5c 100644 --- a/tests/test_vws_exceptions.py +++ b/tests/test_vws_exceptions.py @@ -105,10 +105,68 @@ def test_add_bad_name( def test_request_quota_reached() -> None: - """ - See https://github.com/VWS-Python/vws-python/issues/822 for writing - this test. - """ + """A ``RequestQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(request_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=RequestQuotaReachedError) as exc: + vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +def test_target_quota_reached(high_quality_image: io.BytesIO) -> None: + """A ``TargetQuotaReached`` exception is raised at the quota.""" + database = CloudDatabase(target_quota=0) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=TargetQuotaReachedError) as exc: + vws_client.add_target( + name="x", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.parametrize( + argnames=("state", "expected_exception"), + argvalues=[ + (States.PROJECT_SUSPENDED, ProjectSuspendedError), + (States.PROJECT_HAS_NO_API_ACCESS, ProjectHasNoAPIAccessError), + ], +) +def test_project_state_error( + *, + state: States, + expected_exception: type[VWSError], +) -> None: + """Configured project states raise their matching exceptions.""" + database = CloudDatabase(state=state) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + with pytest.raises(expected_exception=expected_exception) as exc: + vws_client.list_targets() + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN def test_fail(high_quality_image: io.BytesIO) -> None: From 27ddee5e3c3c32425171c3cce42925a6858f5570 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:00:46 +0000 Subject: [PATCH 2/3] [pre-commit.ci lite] apply automatic fixes --- src/vws/exceptions/base_exceptions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vws/exceptions/base_exceptions.py b/src/vws/exceptions/base_exceptions.py index 4b8727e79..ce96ac4e0 100644 --- a/src/vws/exceptions/base_exceptions.py +++ b/src/vws/exceptions/base_exceptions.py @@ -38,7 +38,7 @@ class VWSError(Exception): https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes. """ - _exceptions_by_result_code: ClassVar[dict[str, type["VWSError"]]] = {} + _exceptions_by_result_code: ClassVar[dict[str, type[VWSError]]] = {} def __init__(self, response: Response) -> None: """ @@ -52,7 +52,7 @@ def __init__(self, response: Response) -> None: def register_exceptions_by_result_code( cls, *, - exceptions_by_result_code: Mapping[str, type["VWSError"]], + exceptions_by_result_code: Mapping[str, type[VWSError]], ) -> None: """Register ``result_code`` to exception mappings.""" cls._exceptions_by_result_code.update(exceptions_by_result_code) @@ -63,7 +63,7 @@ def from_result_code( *, result_code: str, response: Response, - ) -> "VWSError": + ) -> VWSError: """Create the mapped exception for a VWS ``result_code``.""" exception_type = cls._exceptions_by_result_code[result_code] return exception_type(response=response) From 65acc0e4817ddd742e67734ae46e7c0a18187334 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 4 Aug 2026 12:11:26 +0100 Subject: [PATCH 3/3] Fix Ruff typing import checks --- conftest.py | 4 ++-- src/vws/_async_vws_request.py | 4 ++-- src/vws/_vws_request.py | 4 ++-- src/vws/async_vumark_service.py | 2 +- src/vws/async_vws.py | 2 +- src/vws/exceptions/base_exceptions.py | 4 ++-- src/vws/exceptions/custom_exceptions.py | 2 +- src/vws/transports.py | 6 ++++-- src/vws/vumark_service.py | 2 +- src/vws/vws.py | 2 +- tests/conftest.py | 6 +++--- tests/test_async_cloud_reco_exceptions.py | 2 +- tests/test_async_query.py | 2 +- tests/test_async_vws.py | 2 +- tests/test_cloud_reco_exceptions.py | 2 +- tests/test_query.py | 2 +- tests/test_transports.py | 2 +- tests/test_vws.py | 2 +- 18 files changed, 27 insertions(+), 25 deletions(-) diff --git a/conftest.py b/conftest.py index 8e5442cf0..f82cefc93 100644 --- a/conftest.py +++ b/conftest.py @@ -1,8 +1,8 @@ """Setup for Sybil.""" -import io +import io # noqa: TC003 import uuid -from collections.abc import Generator +from collections.abc import Generator # noqa: TC003 from doctest import ELLIPSIS from pathlib import Path diff --git a/src/vws/_async_vws_request.py b/src/vws/_async_vws_request.py index 4ebd50c86..bd8d91422 100644 --- a/src/vws/_async_vws_request.py +++ b/src/vws/_async_vws_request.py @@ -5,8 +5,8 @@ from beartype import BeartypeConf, beartype from vws_auth_tools import authorization_header, rfc_1123_date -from vws.response import Response -from vws.transports import AsyncTransport +from vws.response import Response # noqa: TC001 +from vws.transports import AsyncTransport # noqa: TC001 @beartype(conf=BeartypeConf(is_pep484_tower=True)) diff --git a/src/vws/_vws_request.py b/src/vws/_vws_request.py index 8e08cf863..3153dce0c 100644 --- a/src/vws/_vws_request.py +++ b/src/vws/_vws_request.py @@ -5,8 +5,8 @@ from beartype import BeartypeConf, beartype from vws_auth_tools import authorization_header, rfc_1123_date -from vws.response import Response -from vws.transports import Transport +from vws.response import Response # noqa: TC001 +from vws.transports import Transport # noqa: TC001 @beartype(conf=BeartypeConf(is_pep484_tower=True)) diff --git a/src/vws/async_vumark_service.py b/src/vws/async_vumark_service.py index 24191d7c6..9a050afdd 100644 --- a/src/vws/async_vumark_service.py +++ b/src/vws/async_vumark_service.py @@ -11,7 +11,7 @@ from vws.exceptions.custom_exceptions import ServerError from vws.exceptions.vws_exceptions import TooManyRequestsError from vws.transports import AsyncHTTPXTransport, AsyncTransport -from vws.vumark_accept import VuMarkAccept +from vws.vumark_accept import VuMarkAccept # noqa: TC001 @beartype(conf=BeartypeConf(is_pep484_tower=True)) diff --git a/src/vws/async_vws.py b/src/vws/async_vws.py index 5ec523565..aad205768 100644 --- a/src/vws/async_vws.py +++ b/src/vws/async_vws.py @@ -23,7 +23,7 @@ TargetStatuses, TargetSummaryReport, ) -from vws.response import Response +from vws.response import Response # noqa: TC001 from vws.transports import AsyncHTTPXTransport, AsyncTransport diff --git a/src/vws/exceptions/base_exceptions.py b/src/vws/exceptions/base_exceptions.py index ce96ac4e0..616e0682f 100644 --- a/src/vws/exceptions/base_exceptions.py +++ b/src/vws/exceptions/base_exceptions.py @@ -4,12 +4,12 @@ Cloud Recognition Web API. """ -from collections.abc import Mapping +from collections.abc import Mapping # noqa: TC003 from typing import ClassVar from beartype import beartype -from vws.response import Response +from vws.response import Response # noqa: TC001 @beartype diff --git a/src/vws/exceptions/custom_exceptions.py b/src/vws/exceptions/custom_exceptions.py index cf3902264..c53f5db29 100644 --- a/src/vws/exceptions/custom_exceptions.py +++ b/src/vws/exceptions/custom_exceptions.py @@ -6,7 +6,7 @@ from beartype import beartype -from vws.response import Response +from vws.response import Response # noqa: TC001 @beartype diff --git a/src/vws/transports.py b/src/vws/transports.py index 74fadb9fe..e699fc718 100644 --- a/src/vws/transports.py +++ b/src/vws/transports.py @@ -1,7 +1,6 @@ """HTTP transport implementations for VWS clients.""" -from collections.abc import Awaitable -from typing import Protocol, Self, runtime_checkable +from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable import httpx import requests @@ -9,6 +8,9 @@ from vws.response import Response +if TYPE_CHECKING: + from collections.abc import Awaitable + @runtime_checkable class Transport(Protocol): diff --git a/src/vws/vumark_service.py b/src/vws/vumark_service.py index a11eb5fc5..0a5eb98a1 100644 --- a/src/vws/vumark_service.py +++ b/src/vws/vumark_service.py @@ -10,7 +10,7 @@ from vws.exceptions.custom_exceptions import ServerError from vws.exceptions.vws_exceptions import TooManyRequestsError from vws.transports import RequestsTransport, Transport -from vws.vumark_accept import VuMarkAccept +from vws.vumark_accept import VuMarkAccept # noqa: TC001 @beartype(conf=BeartypeConf(is_pep484_tower=True)) diff --git a/src/vws/vws.py b/src/vws/vws.py index b5122ae5f..fc54c30fd 100644 --- a/src/vws/vws.py +++ b/src/vws/vws.py @@ -22,7 +22,7 @@ TargetStatuses, TargetSummaryReport, ) -from vws.response import Response +from vws.response import Response # noqa: TC001 from vws.transports import RequestsTransport, Transport diff --git a/tests/conftest.py b/tests/conftest.py index 820908ba9..15ef17dab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ """Configuration, plugins and fixtures for `pytest`.""" -import io -from collections.abc import AsyncGenerator, Generator -from pathlib import Path +import io # noqa: TC003 +from collections.abc import AsyncGenerator, Generator # noqa: TC003 +from pathlib import Path # noqa: TC003 from typing import BinaryIO, Literal import pytest diff --git a/tests/test_async_cloud_reco_exceptions.py b/tests/test_async_cloud_reco_exceptions.py index cbe2b22cc..f425be299 100644 --- a/tests/test_async_cloud_reco_exceptions.py +++ b/tests/test_async_cloud_reco_exceptions.py @@ -2,7 +2,7 @@ AsyncCloudRecoService. """ -import io +import io # noqa: TC003 import uuid from http import HTTPStatus diff --git a/tests/test_async_query.py b/tests/test_async_query.py index 2de1ce833..2026d491a 100644 --- a/tests/test_async_query.py +++ b/tests/test_async_query.py @@ -1,6 +1,6 @@ """Tests for the ``AsyncCloudRecoService`` querying functionality.""" -import io +import io # noqa: TC003 import uuid from typing import BinaryIO diff --git a/tests/test_async_vws.py b/tests/test_async_vws.py index c08cf78a5..f59c5f2e4 100644 --- a/tests/test_async_vws.py +++ b/tests/test_async_vws.py @@ -1,7 +1,7 @@ """Tests for async helper functions for managing a Vuforia database.""" import base64 -import io +import io # noqa: TC003 import uuid from typing import BinaryIO diff --git a/tests/test_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py index 43a263d40..cb50a9db5 100644 --- a/tests/test_cloud_reco_exceptions.py +++ b/tests/test_cloud_reco_exceptions.py @@ -1,6 +1,6 @@ """Tests for exceptions raised when using the CloudRecoService.""" -import io +import io # noqa: TC003 import uuid from http import HTTPStatus diff --git a/tests/test_query.py b/tests/test_query.py index 66d596118..dac206b25 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,7 +1,7 @@ """Tests for the ``CloudRecoService`` querying functionality.""" import datetime -import io +import io # noqa: TC003 import uuid from typing import BinaryIO diff --git a/tests/test_transports.py b/tests/test_transports.py index f208339d4..c039ea7a1 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -1,6 +1,6 @@ """Tests for HTTP transport implementations.""" -import io +import io # noqa: TC003 import uuid from http import HTTPStatus diff --git a/tests/test_vws.py b/tests/test_vws.py index 29bd3aa3b..dff029c2f 100644 --- a/tests/test_vws.py +++ b/tests/test_vws.py @@ -2,7 +2,7 @@ import base64 import datetime -import io +import io # noqa: TC003 import secrets import uuid from typing import BinaryIO