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]