@@ -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