diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a89a87c..21f12ba8 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 091929e2..3a0e1e1d 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 8f5a347a..fceb5080 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 dea6bb93..b1199303 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/conftest.py b/conftest.py index 8e5442cf..f82cefc9 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/newsfragments/822.change.rst b/newsfragments/822.change.rst new file mode 100644 index 00000000..18195db7 --- /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 99e3bb1b..8bdb0808 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/_async_vws_request.py b/src/vws/_async_vws_request.py index 4ebd50c8..bd8d9142 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 8e08cf86..3153dce0 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 24191d7c..9a050afd 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 5ec52356..aad20576 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 4b8727e7..616e0682 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 @@ -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) diff --git a/src/vws/exceptions/custom_exceptions.py b/src/vws/exceptions/custom_exceptions.py index cf390226..c53f5db2 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/exceptions/vws_exceptions.py b/src/vws/exceptions/vws_exceptions.py index 3e7e1226..3749b561 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/src/vws/transports.py b/src/vws/transports.py index 74fadb9f..e699fc71 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 a11eb5fc..0a5eb98a 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 b5122ae5..fc54c30f 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 820908ba..15ef17da 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 cbe2b22c..f425be29 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 2de1ce83..2026d491 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 c08cf78a..f59c5f2e 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_async_vws_exceptions.py b/tests/test_async_vws_exceptions.py index cd35854f..daa57423 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_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py index 43a263d4..cb50a9db 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 66d59611..dac206b2 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 f208339d..c039ea7a 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 29bd3aa3..dff029c2 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 diff --git a/tests/test_vws_exceptions.py b/tests/test_vws_exceptions.py index 92b33d53..f05436e5 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: