Skip to content

Commit e4352e5

Browse files
feat(python): add aiohttp auto-detection to Python SDK generator (#14469)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 45fe2d4 commit e4352e5

6 files changed

Lines changed: 293 additions & 3 deletions

File tree

generators/python-v2/sdk/src/wire-tests/WireTestSetupGenerator.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,26 @@ def _is_xdist_worker(config: pytest.Config) -> bool:
466466
return hasattr(config, "workerinput")
467467
468468
469+
def _has_httpx_aiohttp() -> bool:
470+
"""Check if httpx_aiohttp is importable."""
471+
try:
472+
import httpx_aiohttp # type: ignore[import-not-found] # noqa: F401
473+
474+
return True
475+
except ImportError:
476+
return False
477+
478+
479+
def pytest_collection_modifyitems(config: pytest.Config, items: list) -> None:
480+
"""Auto-skip @pytest.mark.aiohttp tests when httpx_aiohttp is not installed."""
481+
if _has_httpx_aiohttp():
482+
return
483+
skip_aiohttp = pytest.mark.skip(reason="httpx_aiohttp not installed")
484+
for item in items:
485+
if "aiohttp" in item.keywords:
486+
item.add_marker(skip_aiohttp)
487+
488+
469489
def pytest_configure(config: pytest.Config) -> None:
470490
"""
471491
Pytest hook that runs during test session setup.

generators/python/sdk/versions.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
22
# For unreleased changes, use unreleased.yml
3+
- version: 5.3.0
4+
changelogEntry:
5+
- summary: |
6+
Add aiohttp auto-detection for async HTTP client. When `httpx_aiohttp` is installed,
7+
the generated SDK automatically uses `HttpxAiohttpClient` for async operations instead
8+
of the default `httpx.AsyncClient`. Adds `DefaultAioHttpClient` and `DefaultAsyncHttpxClient`
9+
convenience classes, optional `[aiohttp]` extra in pyproject.toml, pytest markers for
10+
aiohttp-specific tests, and two-pass CI testing (standard + aiohttp).
11+
type: feat
12+
createdAt: "2026-04-01"
13+
irVersion: 65
14+
315
- version: 5.2.0
416
changelogEntry:
517
- summary: |

generators/python/src/fern_python/cli/abstract_generator.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,14 @@ def _get_github_workflow_legacy(
385385
workflow_yaml += """
386386
- name: Test
387387
run: poetry run pytest -rP -n auto .
388+
"""
389+
# Add aiohttp extra install and test steps
390+
workflow_yaml += """
391+
- name: Install aiohttp extra
392+
run: poetry install --extras aiohttp
393+
394+
- name: Test (aiohttp)
395+
run: poetry run pytest -rP -n auto -m aiohttp .
388396
"""
389397
if output_mode.publish_info is not None:
390398
publish_info_union = output_mode.publish_info.get_as_union()
@@ -477,6 +485,14 @@ def _get_github_workflow(
477485
workflow_yaml += """
478486
- name: Test
479487
run: poetry run pytest -rP -n auto .
488+
"""
489+
# Add aiohttp extra install and test steps
490+
workflow_yaml += """
491+
- name: Install aiohttp extra
492+
run: poetry install --extras aiohttp
493+
494+
- name: Test (aiohttp)
495+
run: poetry run pytest -rP -n auto -m aiohttp .
480496
"""
481497
if output_mode.publish_info is not None:
482498
publish_info_union = output_mode.publish_info.get_as_union()

generators/python/src/fern_python/codegen/pyproject_toml.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,9 @@ def to_string(self) -> str:
271271
[tool.pytest.ini_options]
272272
testpaths = [ "tests" ]
273273
asyncio_mode = "auto"
274+
markers = [
275+
"aiohttp: tests that require httpx_aiohttp to be installed",
276+
]
274277
275278
[tool.mypy]
276279
plugins = ["pydantic.mypy"]{mypy_exclude_config}

generators/python/src/fern_python/generators/sdk/client_generator/root_client_generator.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def generate(self, source_file: SourceFile) -> None:
205205
declaration=class_declaration,
206206
should_export=False,
207207
)
208+
source_file.add_arbitrary_code(AST.CodeWriter(self._write_make_default_async_client))
208209
source_file.add_class_declaration(
209210
declaration=async_class_declaration,
210211
should_export=False,
@@ -1431,6 +1432,23 @@ def _get_client_wrapper_kwargs(
14311432
),
14321433
),
14331434
)
1435+
elif is_async:
1436+
client_wrapper_constructor_kwargs.append(
1437+
(
1438+
ClientWrapperGenerator.HTTPX_CLIENT_MEMBER_NAME,
1439+
AST.Expression(
1440+
AST.ConditionalExpression(
1441+
left=AST.Expression(f"{RootClientGenerator.HTTPX_CLIENT_CONSTRUCTOR_PARAMETER_NAME}"),
1442+
right=AST.Expression(
1443+
f"_make_default_async_client(timeout={timeout_local_variable}, follow_redirects={self.FOLLOW_REDIRECTS_CONSTRUCTOR_PARAMETER_NAME})"
1444+
),
1445+
test=AST.Expression(
1446+
f"{RootClientGenerator.HTTPX_CLIENT_CONSTRUCTOR_PARAMETER_NAME} is not None"
1447+
),
1448+
),
1449+
),
1450+
)
1451+
)
14341452
else:
14351453
client_wrapper_constructor_kwargs.append(
14361454
(
@@ -1440,11 +1458,11 @@ def _get_client_wrapper_kwargs(
14401458
left=AST.Expression(f"{RootClientGenerator.HTTPX_CLIENT_CONSTRUCTOR_PARAMETER_NAME}"),
14411459
right=AST.ConditionalExpression(
14421460
left=AST.ClassInstantiation(
1443-
HttpX.ASYNC_CLIENT if is_async else HttpX.CLIENT,
1461+
HttpX.CLIENT,
14441462
kwargs=httpx_client_kwargs_with_redirects,
14451463
),
14461464
right=AST.ClassInstantiation(
1447-
HttpX.ASYNC_CLIENT if is_async else HttpX.CLIENT,
1465+
HttpX.CLIENT,
14481466
kwargs=httpx_client_kwargs_without_redirects,
14491467
),
14501468
test=AST.Expression(f"{self.FOLLOW_REDIRECTS_CONSTRUCTOR_PARAMETER_NAME} is not None"),
@@ -1480,6 +1498,35 @@ def _get_client_wrapper_kwargs(
14801498

14811499
return client_wrapper_constructor_kwargs
14821500

1501+
def _write_make_default_async_client(self, writer: AST.NodeWriter) -> None:
1502+
writer.write_line("")
1503+
writer.write_line("def _make_default_async_client(")
1504+
with writer.indent():
1505+
writer.write_line("timeout: typing.Optional[float],")
1506+
writer.write_line("follow_redirects: typing.Optional[bool],")
1507+
writer.write_line(") -> httpx.AsyncClient:")
1508+
with writer.indent():
1509+
writer.write_line("try:")
1510+
with writer.indent():
1511+
writer.write_line("import httpx_aiohttp # type: ignore[import-not-found]")
1512+
writer.write_line("except ImportError:")
1513+
with writer.indent():
1514+
writer.write_line("pass")
1515+
writer.write_line("else:")
1516+
with writer.indent():
1517+
writer.write_line("if follow_redirects is not None:")
1518+
with writer.indent():
1519+
writer.write_line(
1520+
"return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout, follow_redirects=follow_redirects)"
1521+
)
1522+
writer.write_line("return httpx_aiohttp.HttpxAiohttpClient(timeout=timeout)")
1523+
writer.write_line("")
1524+
writer.write_line("if follow_redirects is not None:")
1525+
with writer.indent():
1526+
writer.write_line("return httpx.AsyncClient(timeout=timeout, follow_redirects=follow_redirects)")
1527+
writer.write_line("return httpx.AsyncClient(timeout=timeout)")
1528+
writer.write_line("")
1529+
14831530
def _write_get_base_url_function(self, writer: AST.NodeWriter) -> None:
14841531
writer.write_line(f"if {RootClientGenerator.BASE_URL_CONSTRUCTOR_PARAMETER_NAME} is not None:")
14851532
with writer.indent():

generators/python/src/fern_python/generators/sdk/sdk_generator.py

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,14 @@ def run(
118118
)
119119
)
120120

121-
project.add_extra(custom_config.extras)
121+
# Merge user-defined extras with the built-in aiohttp extra
122+
extras = dict(custom_config.extras)
123+
extras["aiohttp"] = ["aiohttp", "httpx-aiohttp"]
124+
project.add_extra(extras)
125+
126+
# Add optional dependencies for aiohttp support
127+
project.add_dependency(dependency=AST.Dependency(name="httpx-aiohttp", version="0.1.8", optional=True))
128+
project.add_dependency(dependency=AST.Dependency(name="aiohttp", version="3.0", optional=True))
122129

123130
for dep, bas_dep_value in custom_config.extra_dev_dependencies.items():
124131
if type(bas_dep_value) is str:
@@ -280,6 +287,18 @@ def run(
280287
],
281288
)
282289

290+
# Generate _default_clients.py with aiohttp auto-detection convenience classes
291+
self._generate_default_clients(
292+
context=context,
293+
project=project,
294+
)
295+
296+
# Generate test_aiohttp_autodetect.py test file
297+
self._generate_aiohttp_test(
298+
context=context,
299+
project=project,
300+
)
301+
283302
for subpackage_id in ir.subpackages.keys():
284303
subpackage = ir.subpackages[subpackage_id]
285304
if subpackage.has_endpoints_in_tree or (
@@ -688,6 +707,179 @@ def _generate_error(
688707
ErrorGenerator(context=context, error=error).generate(source_file=source_file)
689708
project.write_source_file(source_file=source_file, filepath=filepath)
690709

710+
def _generate_aiohttp_test(
711+
self,
712+
context: SdkGeneratorContext,
713+
project: Project,
714+
) -> None:
715+
package_name = project._project_config.package_name if project._project_config is not None else "package"
716+
contents = f'''import importlib
717+
import sys
718+
import unittest
719+
from unittest import mock
720+
721+
import httpx
722+
import pytest
723+
724+
725+
class TestMakeDefaultAsyncClientWithoutAiohttp(unittest.TestCase):
726+
"""Tests for _make_default_async_client when httpx_aiohttp is NOT installed."""
727+
728+
def test_returns_httpx_async_client(self) -> None:
729+
"""When httpx_aiohttp is not installed, returns plain httpx.AsyncClient."""
730+
with mock.patch.dict(sys.modules, {{"httpx_aiohttp": None}}):
731+
from {package_name}.client import _make_default_async_client
732+
733+
client = _make_default_async_client(timeout=60, follow_redirects=True)
734+
self.assertIsInstance(client, httpx.AsyncClient)
735+
self.assertEqual(client.timeout.read, 60)
736+
self.assertTrue(client.follow_redirects)
737+
738+
def test_follow_redirects_none(self) -> None:
739+
"""When follow_redirects is None, omits it from httpx.AsyncClient."""
740+
with mock.patch.dict(sys.modules, {{"httpx_aiohttp": None}}):
741+
from {package_name}.client import _make_default_async_client
742+
743+
client = _make_default_async_client(timeout=60, follow_redirects=None)
744+
self.assertIsInstance(client, httpx.AsyncClient)
745+
self.assertFalse(client.follow_redirects)
746+
747+
def test_explicit_httpx_client_bypasses_autodetect(self) -> None:
748+
"""When user passes httpx_client explicitly, auto-detect is not used."""
749+
explicit_client = httpx.AsyncClient(timeout=60)
750+
result = explicit_client if explicit_client is not None else None
751+
self.assertIs(result, explicit_client)
752+
self.assertEqual(result.timeout.read, 60)
753+
754+
755+
@pytest.mark.aiohttp
756+
class TestMakeDefaultAsyncClientWithAiohttp(unittest.TestCase):
757+
"""Tests for _make_default_async_client when httpx_aiohttp IS installed."""
758+
759+
def test_returns_aiohttp_client(self) -> None:
760+
"""When httpx_aiohttp is installed, returns HttpxAiohttpClient."""
761+
import httpx_aiohttp # type: ignore[import-not-found]
762+
763+
from {package_name}.client import _make_default_async_client
764+
765+
client = _make_default_async_client(timeout=60, follow_redirects=True)
766+
self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient)
767+
self.assertEqual(client.timeout.read, 60)
768+
self.assertTrue(client.follow_redirects)
769+
770+
def test_follow_redirects_none(self) -> None:
771+
"""When httpx_aiohttp is installed and follow_redirects is None, omits it."""
772+
import httpx_aiohttp # type: ignore[import-not-found]
773+
774+
from {package_name}.client import _make_default_async_client
775+
776+
client = _make_default_async_client(timeout=60, follow_redirects=None)
777+
self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient)
778+
self.assertFalse(client.follow_redirects)
779+
780+
781+
class TestDefaultClientsWithoutAiohttp(unittest.TestCase):
782+
"""Tests for _default_clients.py convenience classes (no aiohttp)."""
783+
784+
def test_default_async_httpx_client_defaults(self) -> None:
785+
"""DefaultAsyncHttpxClient applies SDK defaults."""
786+
from {package_name}._default_clients import SDK_DEFAULT_TIMEOUT, DefaultAsyncHttpxClient
787+
788+
client = DefaultAsyncHttpxClient()
789+
self.assertIsInstance(client, httpx.AsyncClient)
790+
self.assertEqual(client.timeout.read, SDK_DEFAULT_TIMEOUT)
791+
self.assertTrue(client.follow_redirects)
792+
793+
def test_default_async_httpx_client_overrides(self) -> None:
794+
"""DefaultAsyncHttpxClient allows overriding defaults."""
795+
from {package_name}._default_clients import DefaultAsyncHttpxClient
796+
797+
client = DefaultAsyncHttpxClient(timeout=30, follow_redirects=False)
798+
self.assertEqual(client.timeout.read, 30)
799+
self.assertFalse(client.follow_redirects)
800+
801+
def test_default_aiohttp_client_raises_without_package(self) -> None:
802+
"""DefaultAioHttpClient raises RuntimeError when httpx_aiohttp not installed."""
803+
import {package_name}._default_clients
804+
805+
with mock.patch.dict(sys.modules, {{"httpx_aiohttp": None}}):
806+
importlib.reload({package_name}._default_clients)
807+
808+
with self.assertRaises(RuntimeError) as ctx:
809+
{package_name}._default_clients.DefaultAioHttpClient()
810+
self.assertIn("pip install {package_name}[aiohttp]", str(ctx.exception))
811+
812+
importlib.reload({package_name}._default_clients)
813+
814+
815+
@pytest.mark.aiohttp
816+
class TestDefaultClientsWithAiohttp(unittest.TestCase):
817+
"""Tests for _default_clients.py when httpx_aiohttp IS installed."""
818+
819+
def test_default_aiohttp_client_defaults(self) -> None:
820+
"""DefaultAioHttpClient works when httpx_aiohttp is installed."""
821+
import httpx_aiohttp # type: ignore[import-not-found]
822+
823+
from {package_name}._default_clients import SDK_DEFAULT_TIMEOUT, DefaultAioHttpClient
824+
825+
client = DefaultAioHttpClient()
826+
self.assertIsInstance(client, httpx_aiohttp.HttpxAiohttpClient)
827+
self.assertEqual(client.timeout.read, SDK_DEFAULT_TIMEOUT)
828+
self.assertTrue(client.follow_redirects)
829+
'''
830+
project.add_source_file("tests/test_aiohttp_autodetect.py", contents)
831+
832+
def _generate_default_clients(
833+
self,
834+
context: SdkGeneratorContext,
835+
project: Project,
836+
) -> None:
837+
package_name = project._project_config.package_name if project._project_config is not None else "package"
838+
filepath = Filepath(
839+
directories=(),
840+
file=Filepath.FilepathPart(module_name="_default_clients"),
841+
)
842+
filepath_nested = project.get_source_file_filepath(filepath, include_src_root=True)
843+
contents = f"""# This file was auto-generated by Fern from our API Definition.
844+
845+
import typing
846+
847+
import httpx
848+
849+
SDK_DEFAULT_TIMEOUT = 60
850+
851+
try:
852+
import httpx_aiohttp # type: ignore[import-not-found]
853+
except ImportError:
854+
855+
class DefaultAioHttpClient(httpx.AsyncClient): # type: ignore
856+
def __init__(self, **kwargs: typing.Any) -> None:
857+
raise RuntimeError(
858+
"To use the aiohttp client, install the aiohttp extra: "
859+
"pip install {package_name}[aiohttp]"
860+
)
861+
862+
else:
863+
864+
class DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore
865+
def __init__(self, **kwargs: typing.Any) -> None:
866+
kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT)
867+
kwargs.setdefault("follow_redirects", True)
868+
super().__init__(**kwargs)
869+
870+
871+
class DefaultAsyncHttpxClient(httpx.AsyncClient):
872+
def __init__(self, **kwargs: typing.Any) -> None:
873+
kwargs.setdefault("timeout", SDK_DEFAULT_TIMEOUT)
874+
kwargs.setdefault("follow_redirects", True)
875+
super().__init__(**kwargs)
876+
"""
877+
project.add_file(filepath_nested, contents)
878+
project.register_export_in_project(
879+
filepath_in_project=filepath,
880+
exports={"DefaultAioHttpClient", "DefaultAsyncHttpxClient"},
881+
)
882+
691883
def _generate_version(
692884
self,
693885
project: Project,

0 commit comments

Comments
 (0)