reflectapi-runtime 0.17.2a2__tar.gz → 0.17.2a3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. reflectapi_runtime-0.17.2a3/.gitignore +1 -0
  2. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/PKG-INFO +1 -1
  3. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/pyproject.toml +1 -1
  4. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/__init__.py +6 -1
  5. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/client.py +256 -125
  6. reflectapi_runtime-0.17.2a3/src/reflectapi_runtime/middleware.py +252 -0
  7. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/streaming.py +8 -5
  8. reflectapi_runtime-0.17.2a3/src/reflectapi_runtime/transport.py +65 -0
  9. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_batch.py +1 -1
  10. reflectapi_runtime-0.17.2a3/tests/test_client.py +909 -0
  11. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_edge_cases.py +23 -11
  12. reflectapi_runtime-0.17.2a3/tests/test_middleware.py +487 -0
  13. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_pydantic_serialization.py +30 -33
  14. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_sse.py +2 -2
  15. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_streaming.py +43 -0
  16. reflectapi_runtime-0.17.2a2/.gitignore +0 -5
  17. reflectapi_runtime-0.17.2a2/src/reflectapi_runtime/middleware.py +0 -258
  18. reflectapi_runtime-0.17.2a2/tests/test_client.py +0 -430
  19. reflectapi_runtime-0.17.2a2/tests/test_middleware.py +0 -704
  20. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/README.md +0 -0
  21. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/auth.py +0 -0
  22. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/batch.py +0 -0
  23. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/exceptions.py +0 -0
  24. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
  25. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/option.py +0 -0
  26. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/response.py +0 -0
  27. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/sse.py +0 -0
  28. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/testing.py +0 -0
  29. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/src/reflectapi_runtime/types.py +0 -0
  30. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/__init__.py +0 -0
  31. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_auth.py +0 -0
  32. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_auth_negative_cases.py +0 -0
  33. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_enhanced_features.py +0 -0
  34. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_exceptions.py +0 -0
  35. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_option.py +0 -0
  36. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_response.py +0 -0
  37. {reflectapi_runtime-0.17.2a2 → reflectapi_runtime-0.17.2a3}/tests/test_testing.py +0 -0
@@ -0,0 +1 @@
1
+ uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflectapi-runtime
3
- Version: 0.17.2a2
3
+ Version: 0.17.2a3
4
4
  Summary: Runtime library for ReflectAPI Python clients
5
5
  Project-URL: Homepage, https://github.com/thepartly/reflectapi
6
6
  Project-URL: Repository, https://github.com/thepartly/reflectapi
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reflectapi-runtime"
7
- version = "0.17.2a2"
7
+ version = "0.17.2a3"
8
8
  description = "Runtime library for ReflectAPI Python clients"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -20,6 +20,7 @@ from .auth import (
20
20
  )
21
21
  from .batch import BatchClient
22
22
  from .client import AsyncClientBase, ClientBase
23
+ from .transport import AsyncClient, Client, Request, Response
23
24
  from .exceptions import (
24
25
  ApiError,
25
26
  ApplicationError,
@@ -55,7 +56,7 @@ from .testing import (
55
56
  )
56
57
  from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
57
58
 
58
- __version__ = "0.17.2a2"
59
+ __version__ = "0.17.2a3"
59
60
 
60
61
  __all__ = [
61
62
  # Authentication
@@ -78,12 +79,16 @@ __all__ = [
78
79
  "ApplicationError",
79
80
  "AsyncCassetteMiddleware",
80
81
  "AsyncClientBase",
82
+ "AsyncClient",
81
83
  "AsyncStreamingClient",
82
84
  "BatchClient",
83
85
  "BatchResult",
84
86
  "CassetteClient",
85
87
  "CassetteMiddleware",
88
+ "Client",
86
89
  "ClientBase",
90
+ "Request",
91
+ "Response",
87
92
  "HAS_HYPOTHESIS",
88
93
  "AsyncMiddleware",
89
94
  "MockClient",
@@ -6,7 +6,7 @@ import datetime
6
6
  import json
7
7
  import time
8
8
  from abc import ABC
9
- from collections.abc import AsyncIterator, Iterator
9
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
10
10
  from typing import Any, TypeVar, overload
11
11
 
12
12
  import httpx
@@ -24,6 +24,7 @@ from .middleware import (
24
24
  from .option import serialize_option_dict
25
25
  from .response import ApiResponse, TransportMetadata
26
26
  from .sse import aparse_sse, parse_sse
27
+ from .transport import AsyncClient, Client, Request, Response
27
28
 
28
29
 
29
30
  # Sentinel object to represent "no validation needed"
@@ -61,7 +62,7 @@ class ClientBase(ABC):
61
62
  headers: dict[str, str] | None = None,
62
63
  middleware: list[SyncMiddleware] | None = None,
63
64
  auth: AuthHandler | httpx.Auth | None = None,
64
- client: httpx.Client | None = None,
65
+ client: Client | httpx.Client | None = None,
65
66
  ) -> None:
66
67
  self.base_url = base_url.rstrip("/")
67
68
  self.middleware_chain = SyncMiddlewareChain(middleware or [])
@@ -165,7 +166,7 @@ class ClientBase(ABC):
165
166
  @overload
166
167
  def _make_request(
167
168
  self,
168
- method: str,
169
+
169
170
  path: str,
170
171
  *,
171
172
  params: dict[str, Any] | None = None,
@@ -178,7 +179,7 @@ class ClientBase(ABC):
178
179
  @overload
179
180
  def _make_request(
180
181
  self,
181
- method: str,
182
+
182
183
  path: str,
183
184
  *,
184
185
  params: dict[str, Any] | None = None,
@@ -191,7 +192,7 @@ class ClientBase(ABC):
191
192
  @overload
192
193
  def _make_request(
193
194
  self,
194
- method: str,
195
+
195
196
  path: str,
196
197
  *,
197
198
  params: dict[str, Any] | None = None,
@@ -204,7 +205,7 @@ class ClientBase(ABC):
204
205
  @overload
205
206
  def _make_request(
206
207
  self,
207
- method: str,
208
+
208
209
  path: str,
209
210
  *,
210
211
  params: dict[str, Any] | None = None,
@@ -216,7 +217,7 @@ class ClientBase(ABC):
216
217
  @overload
217
218
  def _make_request(
218
219
  self,
219
- method: str,
220
+
220
221
  path: str,
221
222
  *,
222
223
  params: dict[str, Any] | None = None,
@@ -229,7 +230,7 @@ class ClientBase(ABC):
229
230
  @overload
230
231
  def _make_request(
231
232
  self,
232
- method: str,
233
+
233
234
  path: str,
234
235
  *,
235
236
  params: dict[str, Any] | None = None,
@@ -241,7 +242,7 @@ class ClientBase(ABC):
241
242
 
242
243
  def _validate_request_params(
243
244
  self,
244
- json_data: dict[str, Any] | None,
245
+ json_data: Any | None,
245
246
  json_model: BaseModel | None,
246
247
  ) -> None:
247
248
  """Validate request parameters for conflicts."""
@@ -321,58 +322,103 @@ class ClientBase(ABC):
321
322
 
322
323
  return headers
323
324
 
324
- def _build_request(
325
+ def _build_client_request(
325
326
  self,
326
- method: str,
327
- url: str,
328
- params: dict[str, Any] | None,
329
- json_data: dict[str, Any] | None,
327
+ path: str,
328
+ json_data: Any | None,
330
329
  json_model: BaseModel | None,
331
330
  headers_model: BaseModel | None,
332
- ) -> httpx.Request:
333
- """Build HTTP request object."""
331
+ ) -> Request:
332
+ """Build ReflectAPI transport request object."""
334
333
  if json_model is not None:
335
- # Serialize Pydantic model
336
334
  content, base_headers = self._serialize_request_body(json_model)
337
335
  headers = self._build_headers(base_headers, headers_model)
338
-
339
- return self._client.build_request(
340
- method=method,
341
- url=url,
342
- params=params,
343
- content=content,
344
- headers=headers,
345
- )
346
- else:
347
- # Handle JSON data with Option types
348
- if json_data is not None:
349
- # Only serialize Option types for dictionaries (complex types)
350
- # Primitive types (int, str, bool, etc.) should be passed directly
351
- if isinstance(json_data, dict):
352
- processed_json_data = serialize_option_dict(json_data)
353
- else:
354
- # Primitive types - pass through directly
355
- processed_json_data = json_data
336
+ elif json_data is not None:
337
+ if isinstance(json_data, dict):
338
+ processed_json_data = serialize_option_dict(json_data)
356
339
  else:
357
340
  processed_json_data = json_data
358
-
359
- # Build headers for requests without json_model
341
+ content = json.dumps(
342
+ processed_json_data,
343
+ default=_json_serializer,
344
+ separators=(",", ":"),
345
+ ).encode("utf-8")
346
+ headers = self._build_headers(
347
+ {"Content-Type": "application/json"}, headers_model
348
+ )
349
+ else:
350
+ content = b""
360
351
  headers = self._build_headers({}, headers_model)
361
352
 
362
- return self._client.build_request(
363
- method=method,
364
- url=url,
365
- params=params,
366
- json=processed_json_data,
367
- headers=headers if headers else None,
368
- )
353
+ return Request(path=path, headers=headers, body=content)
369
354
 
370
- def _execute_request(self, request: httpx.Request) -> httpx.Response:
371
- """Execute HTTP request through middleware chain."""
372
- if self.middleware_chain.middleware:
373
- return self.middleware_chain.execute(request, self._client)
374
- else:
375
- return self._client.send(request)
355
+ def _build_httpx_request(
356
+ self,
357
+ request: Request,
358
+ url: str,
359
+ params: dict[str, Any] | None,
360
+ ) -> httpx.Request:
361
+ """Materialise an httpx.Request from a transport Request.
362
+
363
+ Method is always POST by design.
364
+ """
365
+ request_kwargs: dict[str, Any] = {
366
+ "method": "POST",
367
+ "url": url,
368
+ "params": params,
369
+ "headers": request.headers if request.headers else None,
370
+ }
371
+ if request.body:
372
+ request_kwargs["content"] = request.body
373
+ return self._client.build_request(**request_kwargs)
374
+
375
+ def _make_terminal(
376
+ self,
377
+ params: dict[str, Any] | None,
378
+ ) -> Callable[[Request], Response]:
379
+ """Return the chain-terminal handler for the active transport.
380
+
381
+ For an ``httpx.Client``, the terminal lifts the Request into an
382
+ ``httpx.Request``, sends it, and surfaces the real
383
+ ``httpx.Response`` via ``Response.raw`` so callers can read its
384
+ ``request`` / ``extensions`` / ``history`` from
385
+ :class:`TransportMetadata`.
386
+
387
+ For a custom :class:`Client`, the terminal accepts either a
388
+ :class:`Response` (the Protocol contract) or an ``httpx.Response``;
389
+ the latter is adapted at the boundary for transports that wrap
390
+ httpx directly. *Transitional:* the httpx-shaped path is a
391
+ backward-compat shim for adapters written against the previous
392
+ runtime; new code should return a :class:`Response`.
393
+ """
394
+ if isinstance(self._client, httpx.Client):
395
+ base_url = self.base_url
396
+
397
+ def terminal(req: Request) -> Response:
398
+ url = f"{base_url}/{req.path.lstrip('/')}"
399
+ httpx_req = self._build_httpx_request(req, url, params)
400
+ httpx_resp = self._client.send(httpx_req)
401
+ return Response(
402
+ status=httpx_resp.status_code,
403
+ headers=httpx_resp.headers,
404
+ body=httpx_resp.content,
405
+ raw=httpx_resp,
406
+ )
407
+
408
+ return terminal
409
+
410
+ def terminal(req: Request) -> Response:
411
+ response = self._client.request(req)
412
+ if isinstance(response, httpx.Response):
413
+ return Response(
414
+ status=response.status_code,
415
+ headers=response.headers,
416
+ body=response.content,
417
+ raw=response,
418
+ )
419
+ return response
420
+
421
+ return terminal
376
422
 
377
423
  def _handle_error_response(
378
424
  self,
@@ -455,7 +501,7 @@ class ClientBase(ABC):
455
501
 
456
502
  def _make_request(
457
503
  self,
458
- method: str,
504
+
459
505
  path: str,
460
506
  *,
461
507
  params: dict[str, Any] | None = None,
@@ -469,28 +515,49 @@ class ClientBase(ABC):
469
515
  # Validate request parameters
470
516
  self._validate_request_params(json_data, json_model)
471
517
 
472
- # Build URL and request
473
- url = f"{self.base_url}/{path.lstrip('/')}"
474
- request = self._build_request(
475
- method, url, params, json_data, json_model, headers_model
518
+ # Build the transport request once and drive it through the
519
+ # middleware chain. The chain's terminal handler is transport-
520
+ # specific; middleware itself is transport-agnostic.
521
+ request = self._build_client_request(
522
+ path, json_data, json_model, headers_model
476
523
  )
524
+ terminal = self._make_terminal(params)
477
525
 
478
526
  # Execute request with timing
479
527
  start_time = time.time()
480
528
 
481
529
  try:
482
- response = self._execute_request(request)
483
- metadata = TransportMetadata.from_response(response, start_time)
530
+ client_response = self.middleware_chain.execute(request, terminal)
531
+ # Body / status / headers are read from the structural
532
+ # response so any middleware transforms apply; `raw` is used
533
+ # only as the metadata sidecar (preserves `.request` /
534
+ # `.extensions` / `.history` from the real wire response when
535
+ # available, fallback to the synthetic for custom transports).
536
+ parsed_response = httpx.Response(
537
+ status_code=client_response.status,
538
+ headers=client_response.headers,
539
+ content=client_response.body,
540
+ )
541
+ metadata = TransportMetadata(
542
+ status_code=client_response.status,
543
+ headers=client_response.headers,
544
+ timing=time.time() - start_time,
545
+ raw_response=client_response.raw or parsed_response,
546
+ )
484
547
 
485
548
  # Handle error responses
486
- self._handle_error_response(response, metadata, error_model=error_model)
549
+ self._handle_error_response(
550
+ parsed_response, metadata, error_model=error_model
551
+ )
487
552
 
488
553
  # Validate and return response
489
554
  if response_model is not None:
490
- return self._validate_response_model(response, response_model, metadata)
555
+ return self._validate_response_model(
556
+ parsed_response, response_model, metadata
557
+ )
491
558
  else:
492
559
  # No response_model provided - parse JSON into dict
493
- json_response = self._parse_json_response(response)
560
+ json_response = self._parse_json_response(parsed_response)
494
561
  return ApiResponse(json_response, metadata)
495
562
 
496
563
  except httpx.TimeoutException as e:
@@ -500,7 +567,7 @@ class ClientBase(ABC):
500
567
 
501
568
  def _make_sse_request(
502
569
  self,
503
- method: str,
570
+
504
571
  path: str,
505
572
  *,
506
573
  params: dict[str, Any] | None = None,
@@ -526,9 +593,10 @@ class ClientBase(ABC):
526
593
  self._validate_request_params(json_data, json_model)
527
594
 
528
595
  url = f"{self.base_url}/{path.lstrip('/')}"
529
- request = self._build_request(
530
- method, url, params, json_data, json_model, headers_model
596
+ client_request = self._build_client_request(
597
+ path, json_data, json_model, headers_model
531
598
  )
599
+ request = self._build_httpx_request(client_request, url, params)
532
600
  request.headers["accept"] = "text/event-stream"
533
601
 
534
602
  start_time = time.time()
@@ -587,7 +655,7 @@ class AsyncClientBase(ABC):
587
655
  headers: dict[str, str] | None = None,
588
656
  middleware: list[AsyncMiddleware] | None = None,
589
657
  auth: AuthHandler | httpx.Auth | None = None,
590
- client: httpx.AsyncClient | None = None,
658
+ client: AsyncClient | httpx.AsyncClient | None = None,
591
659
  ) -> None:
592
660
  self.base_url = base_url.rstrip("/")
593
661
  self.middleware_chain = AsyncMiddlewareChain(middleware or [])
@@ -691,7 +759,7 @@ class AsyncClientBase(ABC):
691
759
  @overload
692
760
  async def _make_request(
693
761
  self,
694
- method: str,
762
+
695
763
  path: str,
696
764
  *,
697
765
  params: dict[str, Any] | None = None,
@@ -703,7 +771,7 @@ class AsyncClientBase(ABC):
703
771
  @overload
704
772
  async def _make_request(
705
773
  self,
706
- method: str,
774
+
707
775
  path: str,
708
776
  *,
709
777
  params: dict[str, Any] | None = None,
@@ -716,7 +784,7 @@ class AsyncClientBase(ABC):
716
784
  @overload
717
785
  async def _make_request(
718
786
  self,
719
- method: str,
787
+
720
788
  path: str,
721
789
  *,
722
790
  params: dict[str, Any] | None = None,
@@ -729,7 +797,7 @@ class AsyncClientBase(ABC):
729
797
  @overload
730
798
  async def _make_request(
731
799
  self,
732
- method: str,
800
+
733
801
  path: str,
734
802
  *,
735
803
  params: dict[str, Any] | None = None,
@@ -741,7 +809,7 @@ class AsyncClientBase(ABC):
741
809
  @overload
742
810
  async def _make_request(
743
811
  self,
744
- method: str,
812
+
745
813
  path: str,
746
814
  *,
747
815
  params: dict[str, Any] | None = None,
@@ -754,7 +822,7 @@ class AsyncClientBase(ABC):
754
822
  @overload
755
823
  async def _make_request(
756
824
  self,
757
- method: str,
825
+
758
826
  path: str,
759
827
  *,
760
828
  params: dict[str, Any] | None = None,
@@ -766,7 +834,7 @@ class AsyncClientBase(ABC):
766
834
 
767
835
  def _validate_request_params(
768
836
  self,
769
- json_data: dict[str, Any] | None,
837
+ json_data: Any | None,
770
838
  json_model: BaseModel | None,
771
839
  ) -> None:
772
840
  """Validate request parameters for conflicts."""
@@ -847,58 +915,99 @@ class AsyncClientBase(ABC):
847
915
 
848
916
  return headers
849
917
 
850
- def _build_request(
918
+ def _build_client_request(
851
919
  self,
852
- method: str,
853
- url: str,
854
- params: dict[str, Any] | None,
855
- json_data: dict[str, Any] | None,
920
+ path: str,
921
+ json_data: Any | None,
856
922
  json_model: BaseModel | None,
857
923
  headers_model: BaseModel | None,
858
- ) -> httpx.Request:
859
- """Build HTTP request object."""
924
+ ) -> Request:
925
+ """Build ReflectAPI transport request object."""
860
926
  if json_model is not None:
861
- # Serialize Pydantic model
862
927
  content, base_headers = self._serialize_request_body(json_model)
863
928
  headers = self._build_headers(base_headers, headers_model)
864
-
865
- return self._client.build_request(
866
- method=method,
867
- url=url,
868
- params=params,
869
- content=content,
870
- headers=headers,
871
- )
872
- else:
873
- # Handle JSON data with Option types
874
- if json_data is not None:
875
- # Only serialize Option types for dictionaries (complex types)
876
- # Primitive types (int, str, bool, etc.) should be passed directly
877
- if isinstance(json_data, dict):
878
- processed_json_data = serialize_option_dict(json_data)
879
- else:
880
- # Primitive types - pass through directly
881
- processed_json_data = json_data
929
+ elif json_data is not None:
930
+ if isinstance(json_data, dict):
931
+ processed_json_data = serialize_option_dict(json_data)
882
932
  else:
883
933
  processed_json_data = json_data
884
-
885
- # Build headers for requests without json_model
934
+ content = json.dumps(
935
+ processed_json_data,
936
+ default=_json_serializer,
937
+ separators=(",", ":"),
938
+ ).encode("utf-8")
939
+ headers = self._build_headers(
940
+ {"Content-Type": "application/json"}, headers_model
941
+ )
942
+ else:
943
+ content = b""
886
944
  headers = self._build_headers({}, headers_model)
887
945
 
888
- return self._client.build_request(
889
- method=method,
890
- url=url,
891
- params=params,
892
- json=processed_json_data,
893
- headers=headers if headers else None,
894
- )
946
+ return Request(path=path, headers=headers, body=content)
895
947
 
896
- async def _execute_request(self, request: httpx.Request) -> httpx.Response:
897
- """Execute HTTP request through middleware chain."""
898
- if self.middleware_chain.middleware:
899
- return await self.middleware_chain.execute(request, self._client)
900
- else:
901
- return await self._client.send(request)
948
+ def _build_httpx_request(
949
+ self,
950
+ request: Request,
951
+ url: str,
952
+ params: dict[str, Any] | None,
953
+ ) -> httpx.Request:
954
+ """Materialise an httpx.Request from a transport Request.
955
+
956
+ Method is always POST by design.
957
+ """
958
+ request_kwargs: dict[str, Any] = {
959
+ "method": "POST",
960
+ "url": url,
961
+ "params": params,
962
+ "headers": request.headers if request.headers else None,
963
+ }
964
+ if request.body:
965
+ request_kwargs["content"] = request.body
966
+ return self._client.build_request(**request_kwargs)
967
+
968
+ def _make_terminal(
969
+ self,
970
+ params: dict[str, Any] | None,
971
+ ) -> Callable[[Request], Awaitable[Response]]:
972
+ """Return the chain-terminal handler for the active transport.
973
+
974
+ For an ``httpx.AsyncClient``, the terminal lifts the Request into
975
+ an ``httpx.Request``, sends it, and surfaces the real
976
+ ``httpx.Response`` via ``Response.raw``.
977
+
978
+ For a custom :class:`AsyncClient`, the terminal accepts either a
979
+ :class:`Response` or an ``httpx.Response``; the latter is a
980
+ *transitional* backward-compat shim for adapters written against
981
+ the previous runtime.
982
+ """
983
+ if isinstance(self._client, httpx.AsyncClient):
984
+ base_url = self.base_url
985
+
986
+ async def terminal(req: Request) -> Response:
987
+ url = f"{base_url}/{req.path.lstrip('/')}"
988
+ httpx_req = self._build_httpx_request(req, url, params)
989
+ httpx_resp = await self._client.send(httpx_req)
990
+ return Response(
991
+ status=httpx_resp.status_code,
992
+ headers=httpx_resp.headers,
993
+ body=httpx_resp.content,
994
+ raw=httpx_resp,
995
+ )
996
+
997
+ return terminal
998
+
999
+ async def terminal(req: Request) -> Response:
1000
+ response = await self._client.request(req)
1001
+ if isinstance(response, httpx.Response):
1002
+ return Response(
1003
+ status=response.status_code,
1004
+ headers=response.headers,
1005
+ body=response.content,
1006
+ raw=response,
1007
+ )
1008
+ return response
1009
+
1010
+ return terminal
902
1011
 
903
1012
  def _handle_error_response(
904
1013
  self,
@@ -969,7 +1078,7 @@ class AsyncClientBase(ABC):
969
1078
 
970
1079
  async def _make_request(
971
1080
  self,
972
- method: str,
1081
+
973
1082
  path: str,
974
1083
  *,
975
1084
  params: dict[str, Any] | None = None,
@@ -983,27 +1092,48 @@ class AsyncClientBase(ABC):
983
1092
  # Validate request parameters
984
1093
  self._validate_request_params(json_data, json_model)
985
1094
 
986
- # Build URL and request
987
- url = f"{self.base_url}/{path.lstrip('/')}"
988
- request = self._build_request(
989
- method, url, params, json_data, json_model, headers_model
1095
+ # Build the transport request once and drive it through the
1096
+ # middleware chain. The chain's terminal handler is transport-
1097
+ # specific; middleware itself is transport-agnostic.
1098
+ request = self._build_client_request(
1099
+ path, json_data, json_model, headers_model
990
1100
  )
1101
+ terminal = self._make_terminal(params)
991
1102
 
992
1103
  # Execute request with timing
993
1104
  start_time = time.time()
994
1105
 
995
1106
  try:
996
- response = await self._execute_request(request)
997
- metadata = TransportMetadata.from_response(response, start_time)
1107
+ client_response = await self.middleware_chain.execute(request, terminal)
1108
+ # Body / status / headers are read from the structural
1109
+ # response so any middleware transforms apply; `raw` is used
1110
+ # only as the metadata sidecar (preserves `.request` /
1111
+ # `.extensions` / `.history` from the real wire response when
1112
+ # available, fallback to the synthetic for custom transports).
1113
+ parsed_response = httpx.Response(
1114
+ status_code=client_response.status,
1115
+ headers=client_response.headers,
1116
+ content=client_response.body,
1117
+ )
1118
+ metadata = TransportMetadata(
1119
+ status_code=client_response.status,
1120
+ headers=client_response.headers,
1121
+ timing=time.time() - start_time,
1122
+ raw_response=client_response.raw or parsed_response,
1123
+ )
998
1124
 
999
1125
  # Handle error responses
1000
- self._handle_error_response(response, metadata, error_model=error_model)
1126
+ self._handle_error_response(
1127
+ parsed_response, metadata, error_model=error_model
1128
+ )
1001
1129
 
1002
1130
  # Validate and return response
1003
1131
  if response_model is not None:
1004
- return self._validate_response_model(response, response_model, metadata)
1132
+ return self._validate_response_model(
1133
+ parsed_response, response_model, metadata
1134
+ )
1005
1135
  else:
1006
- json_response = self._parse_json_response(response)
1136
+ json_response = self._parse_json_response(parsed_response)
1007
1137
  return ApiResponse(json_response, metadata)
1008
1138
 
1009
1139
  except httpx.TimeoutException as e:
@@ -1013,7 +1143,7 @@ class AsyncClientBase(ABC):
1013
1143
 
1014
1144
  async def _make_sse_request(
1015
1145
  self,
1016
- method: str,
1146
+
1017
1147
  path: str,
1018
1148
  *,
1019
1149
  params: dict[str, Any] | None = None,
@@ -1039,9 +1169,10 @@ class AsyncClientBase(ABC):
1039
1169
  self._validate_request_params(json_data, json_model)
1040
1170
 
1041
1171
  url = f"{self.base_url}/{path.lstrip('/')}"
1042
- request = self._build_request(
1043
- method, url, params, json_data, json_model, headers_model
1172
+ client_request = self._build_client_request(
1173
+ path, json_data, json_model, headers_model
1044
1174
  )
1175
+ request = self._build_httpx_request(client_request, url, params)
1045
1176
  request.headers["accept"] = "text/event-stream"
1046
1177
 
1047
1178
  start_time = time.time()