From 019f71edc49db6d9f1fcece86061589123fb487e Mon Sep 17 00:00:00 2001 From: jsklan Date: Mon, 16 Mar 2026 17:39:05 -0400 Subject: [PATCH] feat(python): add aiohttp auto-detection for async HTTP client When httpx_aiohttp is installed, the async client automatically uses it instead of plain httpx.AsyncClient. Adds DefaultAioHttpClient and DefaultAsyncHttpxClient convenience classes with SDK defaults. --- .../no-custom-config/pyproject.toml | 7 ++ .../no-custom-config/src/seed/__init__.py | 5 + .../src/seed/_default_clients.py | 33 +++++ .../no-custom-config/src/seed/client.py | 22 +++- .../tests/test_aiohttp_autodetect.py | 114 ++++++++++++++++++ 5 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 seed/python-sdk/exhaustive/no-custom-config/src/seed/_default_clients.py create mode 100644 seed/python-sdk/exhaustive/no-custom-config/tests/test_aiohttp_autodetect.py diff --git a/seed/python-sdk/exhaustive/no-custom-config/pyproject.toml b/seed/python-sdk/exhaustive/no-custom-config/pyproject.toml index 3e83aad7a485..c675d491857e 100644 --- a/seed/python-sdk/exhaustive/no-custom-config/pyproject.toml +++ b/seed/python-sdk/exhaustive/no-custom-config/pyproject.toml @@ -45,10 +45,14 @@ Repository = 'https://github.com/exhaustive/fern' [tool.poetry.dependencies] python = "^3.8" httpx = ">=0.21.2" +httpx-aiohttp = {version = ">=0.1.0", optional = true} pydantic = ">= 1.9.2" pydantic-core = ">=2.18.2" typing_extensions = ">= 4.0.0" +[tool.poetry.extras] +aiohttp = ["httpx-aiohttp"] + [tool.poetry.group.dev.dependencies] mypy = "==1.13.0" pytest = "^7.4.0" @@ -63,6 +67,9 @@ ruff = "==0.11.5" [tool.pytest.ini_options] testpaths = [ "tests" ] asyncio_mode = "auto" +markers = [ + "aiohttp: tests that require httpx_aiohttp to be installed", +] [tool.mypy] plugins = ["pydantic.mypy"] diff --git a/seed/python-sdk/exhaustive/no-custom-config/src/seed/__init__.py b/seed/python-sdk/exhaustive/no-custom-config/src/seed/__init__.py index 0e48fee01c9a..4e7c09719ad5 100644 --- a/seed/python-sdk/exhaustive/no-custom-config/src/seed/__init__.py +++ b/seed/python-sdk/exhaustive/no-custom-config/src/seed/__init__.py @@ -8,12 +8,15 @@ if typing.TYPE_CHECKING: from . import endpoints, general_errors, inlined_requests, no_auth, no_req_body, req_with_headers, types from .client import AsyncSeedExhaustive, SeedExhaustive + from ._default_clients import DefaultAioHttpClient, DefaultAsyncHttpxClient from .general_errors import BadObjectRequestInfo, BadRequestBody from .version import __version__ _dynamic_imports: typing.Dict[str, str] = { "AsyncSeedExhaustive": ".client", "BadObjectRequestInfo": ".general_errors", "BadRequestBody": ".general_errors", + "DefaultAioHttpClient": "._default_clients", + "DefaultAsyncHttpxClient": "._default_clients", "SeedExhaustive": ".client", "__version__": ".version", "endpoints": ".endpoints", @@ -51,6 +54,8 @@ def __dir__(): "AsyncSeedExhaustive", "BadObjectRequestInfo", "BadRequestBody", + "DefaultAioHttpClient", + "DefaultAsyncHttpxClient", "SeedExhaustive", "__version__", "endpoints", diff --git a/seed/python-sdk/exhaustive/no-custom-config/src/seed/_default_clients.py b/seed/python-sdk/exhaustive/no-custom-config/src/seed/_default_clients.py new file mode 100644 index 000000000000..4ca4fb14b364 --- /dev/null +++ b/seed/python-sdk/exhaustive/no-custom-config/src/seed/_default_clients.py @@ -0,0 +1,33 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import httpx + +SDK_DEFAULT_TIMEOUT = 60 + +try: + import httpx_aiohttp +except ImportError: + + class DefaultAioHttpClient(httpx.AsyncClient): # type: ignore + def __init__(self, **kwargs: typing.Any) -> None: + raise RuntimeError( + "To use the aiohttp client, install the aiohttp extra: " + "pip install seed[aiohttp]" + ) + +else: + + class DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: typing.Any) -> None: + kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) + + +class DefaultAsyncHttpxClient(httpx.AsyncClient): + def __init__(self, **kwargs: typing.Any) -> None: + kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT) + kwargs.setdefault("follow_redirects", True) + super().__init__(**kwargs) diff --git a/seed/python-sdk/exhaustive/no-custom-config/src/seed/client.py b/seed/python-sdk/exhaustive/no-custom-config/src/seed/client.py index d184134826ea..c622c2fe4bb0 100644 --- a/seed/python-sdk/exhaustive/no-custom-config/src/seed/client.py +++ b/seed/python-sdk/exhaustive/no-custom-config/src/seed/client.py @@ -124,6 +124,24 @@ def req_with_headers(self): return self._req_with_headers +def _make_default_async_client( + timeout: float, + follow_redirects: typing.Optional[bool], +) -> httpx.AsyncClient: + try: + import httpx_aiohttp + except ImportError: + pass + else: + if follow_redirects is not None: + return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout, follow_redirects=follow_redirects) + return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout) + + if follow_redirects is not None: + return httpx.AsyncClient(timeout=timeout, follow_redirects=follow_redirects) + return httpx.AsyncClient(timeout=timeout) + + class AsyncSeedExhaustive: """ Use this class to access the different functions within the SDK. You can instantiate any number of clients with different configuration that will propagate to these functions. @@ -179,9 +197,7 @@ def __init__( headers=headers, httpx_client=httpx_client if httpx_client is not None - else httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) - if follow_redirects is not None - else httpx.AsyncClient(timeout=_defaulted_timeout), + else _make_default_async_client(timeout=_defaulted_timeout, follow_redirects=follow_redirects), timeout=_defaulted_timeout, logging=logging, ) diff --git a/seed/python-sdk/exhaustive/no-custom-config/tests/test_aiohttp_autodetect.py b/seed/python-sdk/exhaustive/no-custom-config/tests/test_aiohttp_autodetect.py new file mode 100644 index 000000000000..aa142a9e3bb4 --- /dev/null +++ b/seed/python-sdk/exhaustive/no-custom-config/tests/test_aiohttp_autodetect.py @@ -0,0 +1,114 @@ +import importlib +import sys +import typing +import unittest +from unittest import mock + +import httpx +import pytest + + +class TestMakeDefaultAsyncClientWithoutAiohttp(unittest.TestCase): + """Tests for _make_default_async_client when httpx_aiohttp is NOT installed.""" + + def test_returns_httpx_async_client(self) -> None: + """When httpx_aiohttp is not installed, returns plain httpx.AsyncClient.""" + with mock.patch.dict(sys.modules, {"httpx_aiohttp": None}): + from seed.client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=True) + self.assertIsInstance(client, httpx.AsyncClient) + self.assertEqual(client.timeout.read, 60) + self.assertTrue(client.follow_redirects) + + def test_follow_redirects_none(self) -> None: + """When follow_redirects is None, omits it from httpx.AsyncClient.""" + with mock.patch.dict(sys.modules, {"httpx_aiohttp": None}): + from seed.client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=None) + self.assertIsInstance(client, httpx.AsyncClient) + self.assertFalse(client.follow_redirects) + + def test_explicit_httpx_client_bypasses_autodetect(self) -> None: + """When user passes httpx_client explicitly, auto-detect is not used.""" + explicit_client = httpx.AsyncClient(timeout=60) + result = explicit_client if explicit_client is not None else None + self.assertIs(result, explicit_client) + self.assertEqual(result.timeout.read, 60) + + +@pytest.mark.aiohttp +class TestMakeDefaultAsyncClientWithAiohttp(unittest.TestCase): + """Tests for _make_default_async_client when httpx_aiohttp IS installed.""" + + def test_returns_aiohttp_client(self) -> None: + """When httpx_aiohttp is installed, returns HttpxAiohttpClient.""" + import httpx_aiohttp + + from seed.client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=True) + self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient) + self.assertEqual(client.timeout.read, 60) + self.assertTrue(client.follow_redirects) + + def test_follow_redirects_none(self) -> None: + """When httpx_aiohttp is installed and follow_redirects is None, omits it.""" + import httpx_aiohttp + + from seed.client import _make_default_async_client + + client = _make_default_async_client(timeout=60, follow_redirects=None) + self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient) + self.assertFalse(client.follow_redirects) + + +class TestDefaultClientsWithoutAiohttp(unittest.TestCase): + """Tests for _default_clients.py convenience classes (no aiohttp).""" + + def test_default_async_httpx_client_defaults(self) -> None: + """DefaultAsyncHttpxClient applies SDK defaults.""" + from seed._default_clients import SDK_DEFAULT_TIMEOUT, DefaultAsyncHttpxClient + + client = DefaultAsyncHttpxClient() + self.assertIsInstance(client, httpx.AsyncClient) + self.assertEqual(client.timeout.read, SDK_DEFAULT_TIMEOUT) + self.assertTrue(client.follow_redirects) + + def test_default_async_httpx_client_overrides(self) -> None: + """DefaultAsyncHttpxClient allows overriding defaults.""" + from seed._default_clients import DefaultAsyncHttpxClient + + client = DefaultAsyncHttpxClient(timeout=30, follow_redirects=False) + self.assertEqual(client.timeout.read, 30) + self.assertFalse(client.follow_redirects) + + def test_default_aiohttp_client_raises_without_package(self) -> None: + """DefaultAioHttpClient raises RuntimeError when httpx_aiohttp not installed.""" + with mock.patch.dict(sys.modules, {"httpx_aiohttp": None}): + import seed._default_clients + + importlib.reload(seed._default_clients) + + with self.assertRaises(RuntimeError) as ctx: + seed._default_clients.DefaultAioHttpClient() + self.assertIn("pip install seed[aiohttp]", str(ctx.exception)) + + importlib.reload(seed._default_clients) + + +@pytest.mark.aiohttp +class TestDefaultClientsWithAiohttp(unittest.TestCase): + """Tests for _default_clients.py when httpx_aiohttp IS installed.""" + + def test_default_aiohttp_client_defaults(self) -> None: + """DefaultAioHttpClient works when httpx_aiohttp is installed.""" + import httpx_aiohttp + + from seed._default_clients import SDK_DEFAULT_TIMEOUT, DefaultAioHttpClient + + client = DefaultAioHttpClient() + self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient) + self.assertEqual(client.timeout.read, SDK_DEFAULT_TIMEOUT) + self.assertTrue(client.follow_redirects)