From 84c3ac0152b54bf95736c65eb044157ca7080e44 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 7 Sep 2026 10:05:55 +0100 Subject: [PATCH] Add HTTPX2 transports Add HTTPX2Transport and AsyncHTTPX2Transport, which make requests with httpx2, the continuation of httpx maintained by Pydantic. httpx and httpx2 are separate packages with separate client, request, response, timeout and exception classes. The new transports use only httpx2 objects, so they raise httpx2 exceptions, and the existing requests and httpx transports are unchanged. The transports are covered by unit tests against an httpx2 mock transport, and by tests which drive the VWS, Cloud Reco, VuMark and Model Target clients through them against VWS Python Mock, which gained httpx2 interception in 2026.9.6. Closes #3174. Co-Authored-By: Claude Fable 5.1 --- docs/source/index.rst | 38 ++ newsfragments/3174.change | 1 + pyproject.toml | 5 +- spelling_private_dict.txt | 3 + src/vws/transports.py | 193 ++++++++++- tests/test_transports.py | 706 +++++++++++++++++++++++++++++++++++++- 6 files changed, 943 insertions(+), 3 deletions(-) create mode 100644 newsfragments/3174.change diff --git a/docs/source/index.rst b/docs/source/index.rst index c8b5bdaf6..017bfb52f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -186,6 +186,44 @@ Dataset generation happens in the background, and the generated dataset is downl dataset_type=ModelTargetDatasetType.STANDARD, ) +HTTP transports +--------------- + +By default, the synchronous clients make requests with `requests`_ and the asynchronous clients make requests with `httpx`_. +To use another HTTP library, pass a transport from :mod:`vws.transports` to a client. + +Transports are available for `requests`_, `httpx`_ and `HTTPX2`_. +``httpx`` and ``httpx2`` are separate packages with separate client, response and exception classes. +``HTTPXTransport`` and ``AsyncHTTPXTransport`` use ``httpx`` and raise ``httpx`` exceptions. +``HTTPX2Transport`` and ``AsyncHTTPX2Transport`` use ``httpx2`` and raise ``httpx2`` exceptions. + +.. clear-namespace + +.. code-block:: python + + """List targets using HTTPX2.""" + + import os + + from vws import VWS + from vws.transports import HTTPX2Transport + + server_access_key = os.environ["VWS_SERVER_ACCESS_KEY"] + server_secret_key = os.environ["VWS_SERVER_SECRET_KEY"] + + vws_client = VWS( + server_access_key=server_access_key, + server_secret_key=server_secret_key, + transport=HTTPX2Transport(), + ) + + # This database has no targets. + assert not vws_client.list_targets() + +.. _requests: https://pypi.org/project/requests/ +.. _httpx: https://pypi.org/project/httpx/ +.. _HTTPX2: https://httpx2.pydantic.dev/ + Testing ------- diff --git a/newsfragments/3174.change b/newsfragments/3174.change new file mode 100644 index 000000000..f13d21e0e --- /dev/null +++ b/newsfragments/3174.change @@ -0,0 +1 @@ +Add ``HTTPX2Transport`` and ``AsyncHTTPX2Transport``, which make requests with ``httpx2``, the continuation of ``httpx`` maintained by Pydantic. The ``requests`` and ``httpx`` transports are unchanged. ``httpx`` and ``httpx2`` objects are never mixed: the ``httpx`` transports raise ``httpx`` exceptions and the ``httpx2`` transports raise ``httpx2`` exceptions. diff --git a/pyproject.toml b/pyproject.toml index f9d6f5e9c..39967e4ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dynamic = [ dependencies = [ "beartype>=0.22.9", "httpx>=0.28.0", + "httpx2>=2.12", "requests>=2.32.3", "urllib3>=2.2.3", "vws-auth-tools>=2024.7.12", @@ -86,7 +87,7 @@ optional-dependencies.dev = [ "types-requests==2.33.0.20260712", "vale==3.19.0.0", "vulture==2.16", - "vws-python-mock==2026.8.26.1", + "vws-python-mock==2026.9.6", "vws-test-fixtures==2026.8.26", "yamlfix==1.19.1", "zizmor==1.30.0", @@ -330,6 +331,7 @@ ignore_names = [ "ALWAYS", "AR_CONTROLLER", # Public API classes imported by users from vws.transports + "AsyncHTTPX2Transport", "AsyncHTTPXTransport", "AUTO", # Sphinx @@ -353,6 +355,7 @@ ignore_names = [ "html_theme_options", "html_title", "htmlhelp_basename", + "HTTPX2Transport", "HTTPXTransport", "IGES", "intersphinx_mapping", diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 09386f1d4..49fd5ec46 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -4,6 +4,7 @@ BadImage ConnectionErrorPossiblyImageTooLarge DateRangeError Falsy +HTTPX2 ImageTooLarge InactiveProject JSONDecodeError @@ -17,6 +18,7 @@ OopsAnErrorOccurredPossiblyBadNameError ProjectHasNoApiAccess ProjectInactive ProjectSuspended +Pydantic QuotaExceeded RequestQuotaReached RequestTimeTooSkewed @@ -65,6 +67,7 @@ html http https httpx +httpx2 iff io issuecomment diff --git a/src/vws/transports.py b/src/vws/transports.py index e699fc718..7c1b04d52 100644 --- a/src/vws/transports.py +++ b/src/vws/transports.py @@ -1,8 +1,25 @@ -"""HTTP transport implementations for VWS clients.""" +"""HTTP transport implementations for VWS clients. + +Three transport families are available: + +* ``RequestsTransport`` uses ``requests``. It is synchronous only, and + is the default transport for the synchronous clients. +* ``HTTPXTransport`` and ``AsyncHTTPXTransport`` use ``httpx``. + ``AsyncHTTPXTransport`` is the default transport for the + asynchronous clients. +* ``HTTPX2Transport`` and ``AsyncHTTPX2Transport`` use ``httpx2``, the + continuation of ``httpx`` maintained by Pydantic. + +``httpx`` and ``httpx2`` are separate packages with separate client, +request, response, timeout and exception classes. Each transport uses +exactly one of them: the ``httpx`` transports raise ``httpx`` +exceptions, and the ``httpx2`` transports raise ``httpx2`` exceptions. +""" from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable import httpx +import httpx2 import requests from beartype import BeartypeConf, beartype @@ -191,6 +208,120 @@ def __call__( ) +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def _httpx2_timeout( + *, + request_timeout: float | tuple[float, float], +) -> httpx2.Timeout: + """The ``httpx2`` timeout for a request timeout. + + Args: + request_timeout: The timeout for the request. A float sets + both the connect and read timeouts. A (connect, read) + tuple sets them individually. + + Returns: + The equivalent ``httpx2`` timeout. + """ + match request_timeout: + case tuple() as timeout: + connect_timeout, read_timeout = timeout + case timeout: + connect_timeout = timeout + read_timeout = timeout + + return httpx2.Timeout( + connect=connect_timeout, + read=read_timeout, + write=None, + pool=None, + ) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +def _response_from_httpx2(*, httpx2_response: httpx2.Response) -> Response: + """Convert an ``httpx2`` response to a ``Response``. + + Args: + httpx2_response: The response to convert. + + Returns: + A Response populated from the ``httpx2`` response. + """ + content = bytes(httpx2_response.content) + request_content = httpx2_response.request.content + + return Response( + text=httpx2_response.text, + url=str(object=httpx2_response.url), + status_code=httpx2_response.status_code, + headers=dict(httpx2_response.headers), + request_body=bytes(request_content) or None, + tell_position=len(content), + content=content, + ) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class HTTPX2Transport: + """HTTP transport using the ``httpx2`` library. + + ``httpx2`` is the continuation of ``httpx`` maintained by + Pydantic. Its client, request, response, timeout and exception + classes are distinct from the ``httpx`` ones, so this transport + raises ``httpx2`` exceptions, not ``httpx`` ones. + A single ``httpx2.Client`` is reused across requests + for connection pooling. + """ + + def __init__(self) -> None: + """Create an ``HTTPX2Transport``.""" + self._client = httpx2.Client() + + def close(self) -> None: + """Close the underlying ``httpx2.Client``.""" + self._client.close() + + def __enter__(self) -> Self: + """Enter the context manager.""" + return self + + def __exit__(self, *_args: object) -> None: + """Exit the context manager and close the client.""" + self.close() + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make an HTTP request using ``httpx2``. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the ``httpx2`` response. + """ + httpx2_response = self._client.request( + method=method, + url=url, + headers=headers, + content=data, + timeout=_httpx2_timeout(request_timeout=request_timeout), + follow_redirects=True, + ) + return _response_from_httpx2(httpx2_response=httpx2_response) + + @runtime_checkable class AsyncTransport(Protocol): """Protocol for async HTTP transports used by VWS clients. @@ -313,3 +444,63 @@ async def __call__( tell_position=len(content), content=content, ) + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class AsyncHTTPX2Transport: + """Async HTTP transport using the ``httpx2`` library. + + ``httpx2`` is the continuation of ``httpx`` maintained by + Pydantic. Its client, request, response, timeout and exception + classes are distinct from the ``httpx`` ones, so this transport + raises ``httpx2`` exceptions, not ``httpx`` ones. + A single ``httpx2.AsyncClient`` is reused across requests + for connection pooling. + """ + + def __init__(self) -> None: + """Create an ``AsyncHTTPX2Transport``.""" + self._client = httpx2.AsyncClient() + + async def aclose(self) -> None: + """Close the underlying ``httpx2.AsyncClient``.""" + await self._client.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *_args: object) -> None: + """Exit the async context manager and close the client.""" + await self.aclose() + + async def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make an async HTTP request using ``httpx2``. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the ``httpx2`` response. + """ + httpx2_response = await self._client.request( + method=method, + url=url, + headers=headers, + content=data, + timeout=_httpx2_timeout(request_timeout=request_timeout), + follow_redirects=True, + ) + return _response_from_httpx2(httpx2_response=httpx2_response) diff --git a/tests/test_transports.py b/tests/test_transports.py index c039ea7a1..09c8bf2a0 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -5,19 +5,35 @@ from http import HTTPStatus import httpx +import httpx2 import pytest import respx +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.target import VuMarkTarget from vws import ( VWS, AsyncCloudRecoService, + AsyncModelTargetService, AsyncVuMarkService, AsyncVWS, CloudRecoService, + ModelTargetService, VuMarkService, ) +from vws.model_target_datasets import ( + ModelTargetDatasetType, + ModelTargetModel, +) +from vws.reports import ModelTargetDatasetStatuses, TargetStatuses from vws.response import Response -from vws.transports import AsyncHTTPXTransport, HTTPXTransport +from vws.transports import ( + AsyncHTTPX2Transport, + AsyncHTTPXTransport, + HTTPX2Transport, + HTTPXTransport, +) from vws.vumark_accept import VuMarkAccept @@ -405,3 +421,691 @@ async def test_falsy_async_transport_is_retained( ) == b"vumark-bytes" ) + + +# The mock accepts one hard-coded pair of Model Target Web API OAuth2 +# credentials, which it does not expose. +_MODEL_TARGET_CLIENT_ID = "client-id" +_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105 + +_HTTPX2_URL = "https://example.com/test" +_HTTPX2_REFUSED_URL = "https://example.com/refused" + + +@pytest.fixture(name="httpx2_requests") +def fixture_httpx2_requests( + *, + monkeypatch: pytest.MonkeyPatch, +) -> list[httpx2.Request]: + """Answer every ``httpx2`` request with a fake server, and collect the + requests which it sees. + + The fake server answers ``OK`` to every request except those for + ``_HTTPX2_REFUSED_URL``, which it refuses to connect to. + """ + requests_seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + """Answer an ``httpx2`` request. + + Args: + request: The request to answer. + + Returns: + An ``OK`` response. + + Raises: + httpx2.ConnectError: The request is for the refused URL. + """ + requests_seen.append(request) + if str(object=request.url) == _HTTPX2_REFUSED_URL: + raise httpx2.ConnectError( + message="Connection refused", + request=request, + ) + return httpx2.Response( + status_code=HTTPStatus.OK, + text="OK", + headers={"X-Example": "example"}, + ) + + mock_transport = httpx2.MockTransport(handler=handler) + + class _Client(httpx2.Client): + """A synchronous client which uses the fake server.""" + + def __init__(self) -> None: + """Create a client which uses the fake server.""" + super().__init__(transport=mock_transport) + + class _AsyncClient(httpx2.AsyncClient): + """An asynchronous client which uses the fake server.""" + + def __init__(self) -> None: + """Create a client which uses the fake server.""" + super().__init__(transport=mock_transport) + + monkeypatch.setattr(target=httpx2, name="Client", value=_Client) + monkeypatch.setattr(target=httpx2, name="AsyncClient", value=_AsyncClient) + return requests_seen + + +class TestHTTPX2Transport: + """Tests for ``HTTPX2Transport``.""" + + @staticmethod + def test_float_timeout(httpx2_requests: list[httpx2.Request]) -> None: + """``HTTPX2Transport`` works with a float timeout. + + A float sets both the connect and read timeouts, and the response + is converted in full. + """ + transport = HTTPX2Transport() + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert request.headers["Content-Type"] == "text/plain" + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.url == _HTTPX2_URL + assert response.headers["x-example"] == "example" + assert response.request_body == b"hello" + assert response.content == b"OK" + assert response.tell_position == len(b"OK") + + @staticmethod + def test_tuple_timeout(httpx2_requests: list[httpx2.Request]) -> None: + """``HTTPX2Transport`` works with a (connect, read) timeout + tuple. + """ + transport = HTTPX2Transport() + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 5.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + def test_int_timeout(httpx2_requests: list[httpx2.Request]) -> None: + """``HTTPX2Transport`` works with an int timeout.""" + transport = HTTPX2Transport() + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30, + "read": 30, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + def test_empty_body(httpx2_requests: list[httpx2.Request]) -> None: + """An empty request body is reported as ``None``, as it is for + the ``requests`` and ``httpx`` transports. + """ + transport = HTTPX2Transport() + response = transport( + method="GET", + url=_HTTPX2_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert response.request_body is None + + @staticmethod + def test_context_manager(httpx2_requests: list[httpx2.Request]) -> None: + """``HTTPX2Transport`` can be used as a context manager, and + leaving the context closes the client. + """ + with HTTPX2Transport() as transport: + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + + @staticmethod + def test_close(httpx2_requests: list[httpx2.Request]) -> None: + """Closing the transport closes the client.""" + transport = HTTPX2Transport() + transport.close() + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert not httpx2_requests + + @staticmethod + def test_httpx2_exceptions(httpx2_requests: list[httpx2.Request]) -> None: + """Errors are raised as ``httpx2`` exceptions, which are not + ``httpx`` exceptions. + """ + transport = HTTPX2Transport() + with pytest.raises(expected_exception=httpx2.ConnectError) as exc: + transport( + method="GET", + url=_HTTPX2_REFUSED_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert not isinstance(exc.value, httpx.HTTPError) + + +class TestAsyncHTTPX2Transport: + """Tests for ``AsyncHTTPX2Transport``.""" + + @staticmethod + @pytest.mark.asyncio + async def test_float_timeout( + httpx2_requests: list[httpx2.Request], + ) -> None: + """``AsyncHTTPX2Transport`` works with a float timeout. + + A float sets both the connect and read timeouts, and the response + is converted in full. + """ + transport = AsyncHTTPX2Transport() + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert request.headers["Content-Type"] == "text/plain" + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.url == _HTTPX2_URL + assert response.headers["x-example"] == "example" + assert response.request_body == b"hello" + assert response.content == b"OK" + assert response.tell_position == len(b"OK") + + @staticmethod + @pytest.mark.asyncio + async def test_tuple_timeout( + httpx2_requests: list[httpx2.Request], + ) -> None: + """``AsyncHTTPX2Transport`` works with a (connect, read) + timeout tuple. + """ + transport = AsyncHTTPX2Transport() + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 5.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + @pytest.mark.asyncio + async def test_int_timeout( + httpx2_requests: list[httpx2.Request], + ) -> None: + """``AsyncHTTPX2Transport`` works with an int timeout.""" + transport = AsyncHTTPX2Transport() + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30, + "read": 30, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + @staticmethod + @pytest.mark.asyncio + async def test_empty_body( + httpx2_requests: list[httpx2.Request], + ) -> None: + """An empty request body is reported as ``None``, as it is for + the ``requests`` and ``httpx`` transports. + """ + transport = AsyncHTTPX2Transport() + response = await transport( + method="GET", + url=_HTTPX2_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert response.request_body is None + + @staticmethod + @pytest.mark.asyncio + async def test_context_manager( + httpx2_requests: list[httpx2.Request], + ) -> None: + """``AsyncHTTPX2Transport`` can be used as an async context + manager, and leaving the context closes the client. + """ + async with AsyncHTTPX2Transport() as transport: + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + + @staticmethod + @pytest.mark.asyncio + async def test_aclose(httpx2_requests: list[httpx2.Request]) -> None: + """Closing the transport closes the client.""" + transport = AsyncHTTPX2Transport() + await transport.aclose() + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert not httpx2_requests + + @staticmethod + @pytest.mark.asyncio + async def test_httpx2_exceptions( + httpx2_requests: list[httpx2.Request], + ) -> None: + """Errors are raised as ``httpx2`` exceptions, which are not + ``httpx`` exceptions. + """ + transport = AsyncHTTPX2Transport() + with pytest.raises(expected_exception=httpx2.ConnectError) as exc: + await transport( + method="GET", + url=_HTTPX2_REFUSED_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert not isinstance(exc.value, httpx.HTTPError) + + +class TestHTTPX2TransportWithMock: + """Tests for synchronous clients using ``HTTPX2Transport`` against + the mock. + """ + + @staticmethod + def test_vws_and_cloud_reco(high_quality_image: io.BytesIO) -> None: + """A target can be added with ``VWS`` and found with + ``CloudRecoService``. + """ + database = CloudDatabase() + with ( + MockVWS(processing_time_seconds=0.2) as mock, + HTTPX2Transport() as transport, + ): + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + transport=transport, + ) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + target_record = vws_client.get_target_record(target_id=target_id) + assert target_record.status == TargetStatuses.SUCCESS + + (match,) = cloud_reco_client.query(image=high_quality_image) + assert match.target_id == target_id + + @staticmethod + def test_vumark() -> None: + """A VuMark instance can be generated with ``VuMarkService``.""" + vumark_target = VuMarkTarget(name="vumark-template") + database = VuMarkDatabase(vumark_targets={vumark_target}) + with MockVWS() as mock, HTTPX2Transport() as transport: + mock.add_vumark_database(vumark_database=database) + vumark_client = VuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_target.target_id, + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + assert vumark_bytes.startswith(b"\x89PNG") + + @staticmethod + def test_model_targets(model_target_model: ModelTargetModel) -> None: + """A Model Target dataset can be generated with + ``ModelTargetService``. + """ + with ( + MockVWS(processing_time_seconds=0.2), + HTTPX2Transport() as transport, + ): + model_target_client = ModelTargetService( + client_id=_MODEL_TARGET_CLIENT_ID, + client_secret=_MODEL_TARGET_CLIENT_SECRET, + transport=transport, + ) + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + report = model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + assert report.status == ModelTargetDatasetStatuses.DONE + model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @staticmethod + @pytest.mark.parametrize( + argnames="custom_timeout", + argvalues=[0.1, (5.0, 0.1)], + ids=["float", "tuple"], + ) + def test_timeout(custom_timeout: float | tuple[float, float]) -> None: + """A response which takes longer than the read timeout raises an + ``httpx2`` timeout. + """ + database = CloudDatabase() + sleeps: list[float] = [] + with MockVWS( + response_delay_seconds=0.11, + sleep_fn=sleeps.append, + ) as mock: + mock.add_cloud_database(cloud_database=database) + with HTTPX2Transport() as transport: + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=custom_timeout, + transport=transport, + ) + with pytest.raises(expected_exception=httpx2.ReadTimeout): + vws_client.list_targets() + # The mock sleeps for the read timeout before raising. + assert sleeps == [0.1] + + +async def _add_and_query_target( + *, + database: CloudDatabase, + transport: AsyncHTTPX2Transport, + image: io.BytesIO, +) -> None: + """Add a target with ``AsyncVWS`` and find it with + ``AsyncCloudRecoService``. + + Args: + database: The mock database to add the target to. + transport: The transport for the clients to use. + image: The image to add as a target and then query with. + """ + vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + transport=transport, + ) + target_id = await vws_client.add_target( + name="example", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await vws_client.wait_for_target_processed(target_id=target_id) + target_record = await vws_client.get_target_record(target_id=target_id) + assert target_record.status == TargetStatuses.SUCCESS + + (match,) = await cloud_reco_client.query(image=image) + assert match.target_id == target_id + + +async def _generate_dataset( + *, + transport: AsyncHTTPX2Transport, + model_target_model: ModelTargetModel, +) -> None: + """Generate, wait for and delete a Model Target dataset with + ``AsyncModelTargetService``. + + Args: + transport: The transport for the client to use. + model_target_model: The model to generate a dataset from. + """ + model_target_client = AsyncModelTargetService( + client_id=_MODEL_TARGET_CLIENT_ID, + client_secret=_MODEL_TARGET_CLIENT_SECRET, + transport=transport, + ) + dataset_uuid = await model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + report = await model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + assert report.status == ModelTargetDatasetStatuses.DONE + await model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +class TestAsyncHTTPX2TransportWithMock: + """Tests for asynchronous clients using ``AsyncHTTPX2Transport`` + against the mock. + """ + + @staticmethod + @pytest.mark.asyncio + async def test_vws_and_cloud_reco(high_quality_image: io.BytesIO) -> None: + """A target can be added with ``AsyncVWS`` and found with + ``AsyncCloudRecoService``. + """ + database = CloudDatabase() + with MockVWS(processing_time_seconds=0.2) as mock: + mock.add_cloud_database(cloud_database=database) + async with AsyncHTTPX2Transport() as transport: + await _add_and_query_target( + database=database, + transport=transport, + image=high_quality_image, + ) + + @staticmethod + @pytest.mark.asyncio + async def test_vumark() -> None: + """A VuMark instance can be generated with + ``AsyncVuMarkService``. + """ + vumark_target = VuMarkTarget(name="vumark-template") + database = VuMarkDatabase(vumark_targets={vumark_target}) + with MockVWS() as mock: + mock.add_vumark_database(vumark_database=database) + async with AsyncHTTPX2Transport() as transport: + vumark_client = AsyncVuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + vumark_bytes = await vumark_client.generate_vumark_instance( + target_id=vumark_target.target_id, + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + assert vumark_bytes.startswith(b"\x89PNG") + + @staticmethod + @pytest.mark.asyncio + async def test_model_targets(model_target_model: ModelTargetModel) -> None: + """A Model Target dataset can be generated with + ``AsyncModelTargetService``. + """ + with MockVWS(processing_time_seconds=0.2): + async with AsyncHTTPX2Transport() as transport: + await _generate_dataset( + transport=transport, + model_target_model=model_target_model, + ) + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize( + argnames="custom_timeout", + argvalues=[0.1, (5.0, 0.1)], + ids=["float", "tuple"], + ) + async def test_timeout( + custom_timeout: float | tuple[float, float], + ) -> None: + """A response which takes longer than the read timeout raises an + ``httpx2`` timeout. + """ + database = CloudDatabase() + sleeps: list[float] = [] + with MockVWS( + response_delay_seconds=0.11, + sleep_fn=sleeps.append, + ) as mock: + mock.add_cloud_database(cloud_database=database) + async with AsyncHTTPX2Transport() as transport: + vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=custom_timeout, + transport=transport, + ) + with pytest.raises(expected_exception=httpx2.ReadTimeout): + await vws_client.list_targets() + # The mock sleeps for the read timeout before raising. + assert sleeps == [0.1]