diff --git a/README.md b/README.md index e2b6e256..1eabee59 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,39 @@ To use this SDK, add the dependency to your project: pip install restate_sdk ``` +### Ingress client + +The ingress client factory uses httpx by default. Install its optional dependency with: + +```shell +python -m pip install "restate-sdk[client]" +``` + +```python +from restate import create_client + +async with create_client("http://localhost:8080") as client: + ... +``` + +To use httpx2, install `restate-sdk[client-httpx2]` and select it explicitly: + +```python +async with create_client("http://localhost:8080", backend="httpx2") as client: + ... +``` + +The factory enables HTTP/2 and closes the HTTP client on exit. It requires the +selected package and does not fall back to another package. + +You can also pass an existing async HTTP client to `restate.client.Client`. +The `HttpClient` and `HttpResponse` protocols in `restate.client_types` describe +the required interface. They are also available from `restate`. Both +`httpx.AsyncClient` and `httpx2.AsyncClient` satisfy this interface. `Client` +keeps the supplied object and leaves its lifetime to the caller. Importing +`Client` requires neither HTTP package. Transport errors keep the supplied +client's exception types. + ## Versions The compatibility with Restate is described in the following table. sdk-python versions < 0.10 are deprecated and no longer supported. diff --git a/pyproject.toml b/pyproject.toml index 13b86df0..15bf07b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ lint = ["mypy>=1.11.2", "pyright>=1.1.390", "ruff>=0.6.9"] harness = ["testcontainers", "hypercorn", "httpx"] serde = ["dacite", "pydantic", "msgspec"] client = ["httpx[http2]"] +client-httpx2 = ["httpx2[http2]>=2.12,<3"] adk = ["google-adk>=1.20.0"] openai = ["openai-agents>=0.6.1"] pydantic_ai = ["pydantic-ai-slim>=2.0,<3"] diff --git a/python/restate/__init__.py b/python/restate/__init__.py index 3b27a446..e1f48f5b 100644 --- a/python/restate/__init__.py +++ b/python/restate/__init__.py @@ -26,7 +26,7 @@ from .context import Context, ObjectContext, ObjectSharedContext from .context import WorkflowContext, WorkflowSharedContext from .retry_policy import InvocationRetryPolicy -from .client_types import RestateClient, HttpError, RestateClientSendHandle +from .client_types import HttpClient, HttpResponse, RestateClient, HttpError, RestateClientSendHandle # pylint: disable=line-too-long from .context import ( @@ -76,20 +76,7 @@ def test_harness( raise ImportError("Install restate-sdk[harness] to use this feature") # type: ignore -try: - from .client import create_client -except ImportError: - # we don't have the appropriate dependencies installed - - @asynccontextmanager - async def create_client( - ingress: str, - headers: typing.Optional[dict] = None, - journal_value_codec: typing.Optional[JournalValueCodec] = None, - ) -> typing.AsyncGenerator[RestateClient, None]: - """a dummy client constructor to raise ImportError. Install restate-sdk[client] to use this feature""" - raise ImportError("Install restate-sdk[client] to use this feature") - yield # type: ignore +from .client import create_client __all__ = [ @@ -128,5 +115,7 @@ async def create_client( "RestateClient", "RestateClientSendHandle", "HttpError", + "HttpClient", + "HttpResponse", "create_client", ] diff --git a/python/restate/client.py b/python/restate/client.py index 35ed2382..6c6fd4cc 100644 --- a/python/restate/client.py +++ b/python/restate/client.py @@ -13,11 +13,10 @@ """ from datetime import timedelta -import httpx import typing from contextlib import asynccontextmanager -from .client_types import RestateClient, RestateClientSendHandle, RestateScopedClient, HttpError +from .client_types import HttpClient, RestateClient, RestateClientSendHandle, RestateScopedClient, HttpError from .context import HandlerType from .entry_codec import JournalValueCodec @@ -35,7 +34,7 @@ class Client(RestateClient): def __init__( self, - client: httpx.AsyncClient, + client: HttpClient, headers: typing.Optional[dict] = None, journal_codec: typing.Optional[JournalValueCodec] = None, ): @@ -486,6 +485,8 @@ async def create_client( ingress: str, headers: typing.Optional[dict] = None, journal_value_codec: typing.Optional[JournalValueCodec] = None, + *, + backend: typing.Literal["httpx", "httpx2"] = "httpx", ) -> typing.AsyncGenerator[RestateClient, None]: """ Create a new Restate client. @@ -496,6 +497,29 @@ async def create_client( journal_value_codec: Optional journal value codec. When set, request bodies are encoded and call responses decoded through it. It must match the codec configured on the endpoint. NOTE: This is experimental and may change in future releases. + backend: HTTP package to use. Defaults to httpx. Install restate-sdk[client] for httpx + or restate-sdk[client-httpx2] for httpx2. The selected package must be installed. """ - async with httpx.AsyncClient(base_url=ingress, headers=headers, http2=True) as http_client: + if backend not in ("httpx", "httpx2"): + msg = f"Unsupported HTTP backend: {backend!r}. Expected 'httpx' or 'httpx2'." + raise ValueError(msg) + + http_client_context: typing.AsyncContextManager[HttpClient] + try: + if backend == "httpx": + import httpx + + http_client_context = httpx.AsyncClient(base_url=ingress, headers=headers, http2=True) + else: + import httpx2 + + http_client_context = httpx2.AsyncClient(base_url=ingress, headers=headers, http2=True) + except ModuleNotFoundError as exc: + if exc.name != backend: + raise + extra = "client" if backend == "httpx" else "client-httpx2" + msg = f'The {backend} backend requires {backend}. Install it with: python -m pip install "restate-sdk[{extra}]"' + raise ImportError(msg) from exc + + async with http_client_context as http_client: yield Client(http_client, headers, journal_codec=journal_value_codec) diff --git a/python/restate/client_types.py b/python/restate/client_types.py index 5908f7e3..3489e5b2 100644 --- a/python/restate/client_types.py +++ b/python/restate/client_types.py @@ -23,6 +23,28 @@ O = typing.TypeVar("O") +class HttpResponse(typing.Protocol): + """The buffered HTTP response used by the ingress client.""" + + @property + def status_code(self) -> int: ... + + @property + def reason_phrase(self) -> str: ... + + @property + def text(self) -> str: ... + + @property + def content(self) -> bytes: ... + + +class HttpClient(typing.Protocol): + """An HTTP client that can send ingress requests asynchronously.""" + + async def post(self, url: str, *, headers: dict[str, str], content: bytes) -> HttpResponse: ... + + class RestateClientSendHandle: """ A handle for a send operation. diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 00000000..54cfc170 --- /dev/null +++ b/tests/client.py @@ -0,0 +1,263 @@ +# +# Copyright (c) 2023-2026 - Restate Software, Inc., Restate GmbH +# +# This file is part of the Restate SDK for Python, +# which is released under the MIT license. +# +# You can find a copy of the license in file LICENSE in the root +# directory of this repository or package, or at +# https://github.com/restatedev/sdk-typescript/blob/main/LICENSE +# +"""Tests for optional ingress HTTP clients.""" + +import builtins +from dataclasses import dataclass +import subprocess +import sys +import typing +from unittest.mock import ANY, MagicMock, Mock + +import httpx +import httpx2 +import pytest + +import restate +from restate import HttpClient, HttpError, HttpResponse, JournalValueCodec +from restate.client import Client, create_client +from restate.serde import JsonSerde + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +pytestmark = pytest.mark.anyio + + +@dataclass +class StubResponse: + status_code: int = 200 + reason_phrase: str = "OK" + text: str = '"pong"' + content: bytes = b'"pong"' + + +class StubClient: + def __init__(self, response: HttpResponse): + self.response = response + self.calls: list[tuple[str, dict[str, str], bytes]] = [] + + async def post(self, url: str, *, headers: dict[str, str], content: bytes) -> HttpResponse: + self.calls.append((url, headers, content)) + return self.response + + +def test_import_without_http_packages(): + code = """ +import sys +sys.modules["httpx"] = sys.modules["httpx2"] = None +from restate.client import Client +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +async def test_supplied_client(): + http_client = StubClient(StubResponse()) + client = Client(http_client) + assert client.client is http_client + headers = {"X-Test": "value"} + + result = await client.do_raw_call( + service="greeter", + handler="greet", + input_param="ping", + input_serde=JsonSerde[str](), + output_serde=JsonSerde[str](), + key="object", + scope="tenant", + headers=headers, + idempotency_key="request-1", + limit_key="customer/user", + ) + + assert result == "pong" + assert http_client.calls == [ + ( + "restate/scope/tenant/call/greeter/object/greet", + {"X-Test": "value", "Idempotency-Key": "request-1", "x-restate-limit-key": "customer/user"}, + b'"ping"', + ) + ] + assert headers == {"X-Test": "value"} + + +@pytest.mark.parametrize("status_code", [400, 503]) +async def test_http_error_fields(status_code: int): + response = StubResponse(status_code, "custom reason", "exact body: ΓΌ\n") + with pytest.raises(HttpError) as error: + await Client(StubClient(response)).post("service", "handler", False, b"input") + assert error.value.status_code == status_code + assert error.value.message == response.reason_phrase + assert error.value.body == response.text + assert str(error.value) == f"HTTP {status_code}: custom reason" + + +async def _check_real_client(http_client: HttpClient, respond: Mock): + client = Client(http_client) + assert client.client is http_client + result = await client.do_raw_call( + "service", "handler", "ping", JsonSerde[str](), JsonSerde[str](), headers={"X-Call": "call"} + ) + assert result == "pong" + (call,) = respond.call_args_list + request = call.args[0] + assert str(request.url) == "http://ingress/service/handler" + assert request.headers["x-default"] == "default" + assert request.headers["x-call"] == "call" + assert request.content == b'"ping"' + + +async def test_httpx_client(): + respond = Mock(return_value=httpx.Response(200, content=b'"pong"')) + async with httpx.AsyncClient( + base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx.MockTransport(respond) + ) as client: + await _check_real_client(client, respond) + + +async def test_httpx2_client(): + respond = Mock(return_value=httpx2.Response(200, content=b'"pong"')) + async with httpx2.AsyncClient( + base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx2.MockTransport(respond) + ) as client: + await _check_real_client(client, respond) + + +@pytest.mark.parametrize( + "http, failure", + [ + (httpx, httpx.ReadTimeout("timeout")), + (httpx, httpx.ConnectError("connection")), + (httpx2, httpx2.ReadTimeout("timeout")), + (httpx2, httpx2.ConnectError("connection")), + ], +) +async def test_transport_errors_propagate(http, failure: Exception): + transport = http.MockTransport(Mock(side_effect=failure)) + async with http.AsyncClient(base_url="http://ingress/", transport=transport) as client: + with pytest.raises(type(failure)) as error: + await Client(client).post("service", "handler", False, b"input") + assert error.value is failure + + +@pytest.fixture +def factories(monkeypatch: pytest.MonkeyPatch) -> dict[str, Mock]: + result = {} + for http in (httpx, httpx2): + client = MagicMock() + client.__aenter__.return_value = client + factory = Mock(return_value=client) + monkeypatch.setattr(http, "AsyncClient", factory) + result[http.__name__] = factory + return result + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize("other_installed", [False, True]) +async def test_factory_selects_backend( + backend: typing.Literal["httpx", "httpx2"], + other_installed: bool, + factories: dict[str, Mock], + monkeypatch: pytest.MonkeyPatch, +): + other = "httpx2" if backend == "httpx" else "httpx" + if not other_installed: + monkeypatch.setitem(sys.modules, other, None) # type: ignore[arg-type] + headers = {"X-Default": "default"} + codec = Mock(spec=JournalValueCodec) + http_client = factories[backend].return_value + async with create_client("http://ingress/", headers, codec, backend=backend) as client: + assert isinstance(client, Client) + assert client.client is http_client + assert client.headers is headers + assert client.journal_codec is codec + http_client.__aexit__.assert_not_awaited() + http_client.__aexit__.assert_awaited_once_with(None, None, None) + factories[backend].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) + factories[other].assert_not_called() + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize("other_installed", [False, True]) +async def test_missing_backend_does_not_fall_back( + backend: typing.Literal["httpx", "httpx2"], + other_installed: bool, + factories: dict[str, Mock], + monkeypatch: pytest.MonkeyPatch, +): + other = "httpx2" if backend == "httpx" else "httpx" + monkeypatch.setitem(sys.modules, backend, None) # type: ignore[arg-type] + if not other_installed: + monkeypatch.setitem(sys.modules, other, None) # type: ignore[arg-type] + with pytest.raises(ImportError) as error: + async with create_client("http://ingress/", backend=backend): + pass + extra = "client" if backend == "httpx" else "client-httpx2" + assert f'python -m pip install "restate-sdk[{extra}]"' in str(error.value) + factories[other].assert_not_called() + + +async def test_default_factory_and_positional_arguments(factories: dict[str, Mock]): + headers = {"X-Default": "value"} + codec = Mock(spec=JournalValueCodec) + async with restate.create_client("http://ingress/", headers, codec): + pass + factories["httpx"].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) + factories["httpx2"].assert_not_called() + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +async def test_factory_cleanup_on_error(backend: typing.Literal["httpx", "httpx2"], factories: dict[str, Mock]): + failure = RuntimeError("body failed") + http_client = factories[backend].return_value + with pytest.raises(RuntimeError) as error: + async with create_client("http://ingress/", backend=backend): + raise failure + assert error.value is failure + http_client.__aexit__.assert_awaited_once_with(RuntimeError, failure, ANY) + + +async def test_invalid_backend(): + with pytest.raises(ValueError, match="Unsupported HTTP backend: 'unknown'"): + async with create_client("http://ingress/", backend="unknown"): # type: ignore[arg-type] + pass + + +def test_backend_is_keyword_only(): + with pytest.raises(TypeError): + create_client("http://ingress/", None, None, "httpx2") # type: ignore[misc] + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize( + "failure", [ModuleNotFoundError("missing dependency", name="dependency"), ImportError("broken")] +) +async def test_broken_backend_import_propagates( + backend: typing.Literal["httpx", "httpx2"], + failure: ImportError, + monkeypatch: pytest.MonkeyPatch, +): + original_import = builtins.__import__ + + def import_backend(name, *args, **kwargs): + if name == backend: + raise failure + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_backend) + with pytest.raises(type(failure)) as error: + async with create_client("http://ingress/", backend=backend): + pass + assert error.value is failure diff --git a/uv.lock b/uv.lock index 948f4614..1305dbb1 100644 --- a/uv.lock +++ b/uv.lock @@ -479,7 +479,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -505,7 +505,8 @@ name = "genai-prices" version = "0.0.71" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx2" }, + { name = "httpx2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "httpx2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/e4/5072862613fba039da2b7c981a8649c6c6bbcb2863bd8bc81617c09ce5ee/genai_prices-0.0.71.tar.gz", hash = "sha256:de4db34ec38404f9ef383cb1ab29e204d16ccf27071af0b16d5747ee7affe36b", size = 82105, upload-time = "2026-07-10T00:38:30.491Z" } @@ -1228,15 +1229,37 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.3.0" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ff/b53af1b49bb2cb5fa97c92215614b173ca5c1a99ded80e2d52f15a94fa6d/httpcore2-2.14.0.tar.gz", hash = "sha256:4f57a1a8249fe6af589d935cfd456f1d13bbdcb0f0fc72af9142368fc94811d7", size = 69096, upload-time = "2026-10-18T16:29:21.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/06/a8d6a8f90d11b76d9086242b6bb0ee4b503473bfc8be34c2d5b067275c22/httpcore2-2.14.0-py3-none-any.whl", hash = "sha256:4c4f64f4b544afeb778c42c0ffb5941b29e671157e90ed3f6dc787c4bca90cd1", size = 84291, upload-time = "2026-10-18T16:29:19.743Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, ] [[package]] @@ -1282,17 +1305,63 @@ wheels = [ [[package]] name = "httpx2" -version = "2.3.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "httpx2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1325,11 +1394,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -2900,6 +2969,10 @@ adk = [ client = [ { name = "httpx", extra = ["http2"] }, ] +client-httpx2 = [ + { name = "httpx2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, extra = ["http2"], marker = "python_full_version < '3.11'" }, + { name = "httpx2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, extra = ["http2"], marker = "python_full_version >= '3.11'" }, +] harness = [ { name = "httpx" }, { name = "hypercorn" }, @@ -2946,6 +3019,7 @@ requires-dist = [ { name = "google-adk", marker = "extra == 'adk'", specifier = ">=1.20.0" }, { name = "httpx", marker = "extra == 'harness'" }, { name = "httpx", extras = ["http2"], marker = "extra == 'client'" }, + { name = "httpx2", extras = ["http2"], marker = "extra == 'client-httpx2'", specifier = ">=2.12,<3" }, { name = "hypercorn", marker = "extra == 'harness'" }, { name = "hypercorn", marker = "extra == 'test'" }, { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.0.0" }, @@ -2962,7 +3036,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'lint'", specifier = ">=0.6.9" }, { name = "testcontainers", marker = "extra == 'harness'" }, ] -provides-extras = ["adk", "client", "harness", "langchain", "lint", "openai", "pydantic-ai", "serde", "test", "tracing", "zstd"] +provides-extras = ["adk", "client", "client-httpx2", "harness", "langchain", "lint", "openai", "pydantic-ai", "serde", "test", "tracing", "zstd"] [[package]] name = "rpds-py" @@ -3308,8 +3382,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [