reflectapi-runtime 0.18.0__tar.gz → 0.19.0__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.
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/PKG-INFO +7 -3
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/README.md +6 -2
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/pyproject.toml +1 -1
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/__init__.py +4 -2
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/client.py +220 -117
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/exceptions.py +10 -2
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/response.py +96 -3
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_client.py +136 -1
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_sse.py +50 -1
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/.gitignore +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/auth.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/batch.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/duration.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/middleware.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/partial.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/serde.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/sse.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/streaming.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/testing.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/transport.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/types.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/__init__.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_auth.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_auth_negative_cases.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_batch.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_codegen_regressions.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_edge_cases.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_enhanced_features.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_exceptions.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_middleware.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_partial.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_pydantic_serialization.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_response.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_streaming.py +0 -0
- {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: reflectapi-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
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
|
|
@@ -47,8 +47,12 @@ directly.
|
|
|
47
47
|
- `ClientBase` / `AsyncClientBase` — base classes used by the generated
|
|
48
48
|
`Client` / `AsyncClient`. They wrap `httpx` and handle request build-up,
|
|
49
49
|
Pydantic-based response validation, and middleware.
|
|
50
|
-
- `ApiResponse[T, E]` — typed wrapper around the response value, transport
|
|
51
|
-
metadata,
|
|
50
|
+
- `ApiResponse[T, E, H]` — typed wrapper around the response value, transport
|
|
51
|
+
metadata, the optional typed error, and the API's declared response headers
|
|
52
|
+
(`.headers`).
|
|
53
|
+
- `ApiStream[T, H]` / `AsyncApiStream[T, H]` — iterators returned by streaming
|
|
54
|
+
methods, exposing the response's `metadata` and declared `headers` once
|
|
55
|
+
iteration starts.
|
|
52
56
|
- `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
|
|
53
57
|
exceptions raised by the generated methods on non-2xx, transport, and
|
|
54
58
|
validation failures respectively.
|
|
@@ -10,8 +10,12 @@ directly.
|
|
|
10
10
|
- `ClientBase` / `AsyncClientBase` — base classes used by the generated
|
|
11
11
|
`Client` / `AsyncClient`. They wrap `httpx` and handle request build-up,
|
|
12
12
|
Pydantic-based response validation, and middleware.
|
|
13
|
-
- `ApiResponse[T, E]` — typed wrapper around the response value, transport
|
|
14
|
-
metadata,
|
|
13
|
+
- `ApiResponse[T, E, H]` — typed wrapper around the response value, transport
|
|
14
|
+
metadata, the optional typed error, and the API's declared response headers
|
|
15
|
+
(`.headers`).
|
|
16
|
+
- `ApiStream[T, H]` / `AsyncApiStream[T, H]` — iterators returned by streaming
|
|
17
|
+
methods, exposing the response's `metadata` and declared `headers` once
|
|
18
|
+
iteration starts.
|
|
15
19
|
- `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
|
|
16
20
|
exceptions raised by the generated methods on non-2xx, transport, and
|
|
17
21
|
validation failures respectively.
|
|
@@ -38,7 +38,7 @@ from .hypothesis_strategies import (
|
|
|
38
38
|
)
|
|
39
39
|
from .middleware import AsyncMiddleware
|
|
40
40
|
from .partial import ReflectapiPartialModel
|
|
41
|
-
from .response import ApiResponse, TransportMetadata
|
|
41
|
+
from .response import ApiResponse, ApiStream, AsyncApiStream, TransportMetadata
|
|
42
42
|
from .serde import parse_externally_tagged, serialize_externally_tagged
|
|
43
43
|
from .streaming import AsyncStreamingClient, StreamingResponse
|
|
44
44
|
from .testing import (
|
|
@@ -50,7 +50,7 @@ from .testing import (
|
|
|
50
50
|
)
|
|
51
51
|
from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
|
|
52
52
|
|
|
53
|
-
__version__ = "0.
|
|
53
|
+
__version__ = "0.19.0"
|
|
54
54
|
|
|
55
55
|
__all__ = [
|
|
56
56
|
# Authentication
|
|
@@ -94,6 +94,8 @@ __all__ = [
|
|
|
94
94
|
"parse_externally_tagged",
|
|
95
95
|
"serialize_externally_tagged",
|
|
96
96
|
"StreamingResponse",
|
|
97
|
+
"ApiStream",
|
|
98
|
+
"AsyncApiStream",
|
|
97
99
|
"TestClientMixin",
|
|
98
100
|
"TimeoutError",
|
|
99
101
|
"TransportMetadata",
|
|
@@ -5,9 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import datetime
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
8
9
|
import time
|
|
9
10
|
from abc import ABC
|
|
10
|
-
from collections.abc import
|
|
11
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
|
|
11
12
|
from typing import Any, TypeVar, overload
|
|
12
13
|
|
|
13
14
|
import httpx
|
|
@@ -22,7 +23,7 @@ from .middleware import (
|
|
|
22
23
|
SyncMiddleware,
|
|
23
24
|
SyncMiddlewareChain,
|
|
24
25
|
)
|
|
25
|
-
from .response import ApiResponse, TransportMetadata
|
|
26
|
+
from .response import ApiResponse, ApiStream, AsyncApiStream, TransportMetadata
|
|
26
27
|
from .sse import aparse_sse, parse_sse
|
|
27
28
|
from .transport import AsyncClient, Client, Request, Response
|
|
28
29
|
|
|
@@ -34,6 +35,8 @@ class _NoValidation:
|
|
|
34
35
|
|
|
35
36
|
NO_VALIDATION = _NoValidation()
|
|
36
37
|
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
37
40
|
T = TypeVar("T", bound=BaseModel)
|
|
38
41
|
|
|
39
42
|
|
|
@@ -82,7 +85,11 @@ def _synthesize_raw_response(
|
|
|
82
85
|
)
|
|
83
86
|
|
|
84
87
|
|
|
85
|
-
def _parse_json_body(
|
|
88
|
+
def _parse_json_body(
|
|
89
|
+
body: bytes | str | None,
|
|
90
|
+
metadata: TransportMetadata | None = None,
|
|
91
|
+
response_headers: Any | None = None,
|
|
92
|
+
) -> Any:
|
|
86
93
|
"""Parse a response body as JSON, raising ``ValidationError`` on failure."""
|
|
87
94
|
try:
|
|
88
95
|
return json.loads(body if body is not None else b"")
|
|
@@ -90,6 +97,8 @@ def _parse_json_body(body: bytes | str | None) -> Any:
|
|
|
90
97
|
raise ValidationError(
|
|
91
98
|
f"Failed to parse JSON response: {e}",
|
|
92
99
|
cause=e,
|
|
100
|
+
metadata=metadata,
|
|
101
|
+
headers=response_headers,
|
|
93
102
|
)
|
|
94
103
|
|
|
95
104
|
|
|
@@ -98,6 +107,7 @@ def _raise_for_error_status(
|
|
|
98
107
|
body: bytes | str | None,
|
|
99
108
|
metadata: TransportMetadata,
|
|
100
109
|
error_model: type | None = None,
|
|
110
|
+
response_headers: Any | None = None,
|
|
101
111
|
) -> None:
|
|
102
112
|
"""Raise ``ApplicationError`` for HTTP error responses (4xx, 5xx).
|
|
103
113
|
|
|
@@ -126,13 +136,37 @@ def _raise_for_error_status(
|
|
|
126
136
|
metadata=metadata,
|
|
127
137
|
error_data=error_data,
|
|
128
138
|
typed_error=typed_error,
|
|
139
|
+
headers=response_headers,
|
|
129
140
|
)
|
|
130
141
|
|
|
131
142
|
|
|
143
|
+
def _parse_response_headers(headers: Any, model: type[Any] | None) -> Any | None:
|
|
144
|
+
"""Validate the API's declared response headers into ``model``.
|
|
145
|
+
|
|
146
|
+
Header names are case-insensitive, so they're lowercased to match the
|
|
147
|
+
model's (lowercase) aliases; undeclared headers are ignored. A value that
|
|
148
|
+
doesn't validate reads as absent rather than failing the call, which
|
|
149
|
+
would hide the response's real outcome (e.g. a 503 from a proxy); the raw
|
|
150
|
+
value stays in ``metadata.headers``.
|
|
151
|
+
"""
|
|
152
|
+
if model is None:
|
|
153
|
+
return None
|
|
154
|
+
items = headers.items() if headers is not None else ()
|
|
155
|
+
lowered = {str(name).lower(): str(value) for name, value in items}
|
|
156
|
+
try:
|
|
157
|
+
return model.model_validate(lowered)
|
|
158
|
+
except PydanticValidationError as e:
|
|
159
|
+
invalid = {error["loc"][0] for error in e.errors() if error["loc"]}
|
|
160
|
+
logger.debug("ignoring malformed response headers: %s", sorted(invalid))
|
|
161
|
+
valid = {name: value for name, value in lowered.items() if name not in invalid}
|
|
162
|
+
return model.model_validate(valid)
|
|
163
|
+
|
|
164
|
+
|
|
132
165
|
def _validate_body(
|
|
133
166
|
body: bytes | str | None,
|
|
134
167
|
response_model: type[T] | type[Any] | str | _NoValidation,
|
|
135
168
|
metadata: TransportMetadata,
|
|
169
|
+
response_headers: Any | None = None,
|
|
136
170
|
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
137
171
|
"""Validate a response body using Pydantic via TypeAdapter.
|
|
138
172
|
|
|
@@ -147,20 +181,28 @@ def _validate_body(
|
|
|
147
181
|
or response_model is NO_VALIDATION
|
|
148
182
|
or response_model is Any
|
|
149
183
|
):
|
|
150
|
-
return ApiResponse(
|
|
184
|
+
return ApiResponse(
|
|
185
|
+
_parse_json_body(body, metadata, response_headers),
|
|
186
|
+
metadata,
|
|
187
|
+
response_headers,
|
|
188
|
+
)
|
|
151
189
|
|
|
152
190
|
try:
|
|
153
191
|
ta = TypeAdapter(response_model)
|
|
154
192
|
if isinstance(body, (bytes, bytearray, str)):
|
|
155
193
|
validated_data = ta.validate_json(body)
|
|
156
194
|
else:
|
|
157
|
-
validated_data = ta.validate_python(
|
|
158
|
-
|
|
195
|
+
validated_data = ta.validate_python(
|
|
196
|
+
_parse_json_body(body, metadata, response_headers)
|
|
197
|
+
)
|
|
198
|
+
return ApiResponse(validated_data, metadata, response_headers)
|
|
159
199
|
except PydanticValidationError as e:
|
|
160
200
|
raise ValidationError(
|
|
161
201
|
f"Response validation failed: {e}",
|
|
162
202
|
validation_errors=e.errors(),
|
|
163
203
|
cause=e,
|
|
204
|
+
metadata=metadata,
|
|
205
|
+
headers=response_headers,
|
|
164
206
|
)
|
|
165
207
|
|
|
166
208
|
|
|
@@ -288,6 +330,7 @@ class ClientBase(ABC):
|
|
|
288
330
|
headers_model: BaseModel | None = None,
|
|
289
331
|
response_model: type[T],
|
|
290
332
|
error_model: type[Any] | None = None,
|
|
333
|
+
response_headers_model: type[Any] | None = None,
|
|
291
334
|
) -> ApiResponse[T]: ...
|
|
292
335
|
|
|
293
336
|
@overload
|
|
@@ -302,6 +345,7 @@ class ClientBase(ABC):
|
|
|
302
345
|
headers_model: BaseModel | None = None,
|
|
303
346
|
response_model: None = None,
|
|
304
347
|
error_model: type[Any] | None = None,
|
|
348
|
+
response_headers_model: type[Any] | None = None,
|
|
305
349
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
306
350
|
|
|
307
351
|
@overload
|
|
@@ -316,6 +360,7 @@ class ClientBase(ABC):
|
|
|
316
360
|
headers_model: BaseModel | None = None,
|
|
317
361
|
response_model: type[Any],
|
|
318
362
|
error_model: type[Any] | None = None,
|
|
363
|
+
response_headers_model: type[Any] | None = None,
|
|
319
364
|
) -> ApiResponse[Any]: ...
|
|
320
365
|
|
|
321
366
|
@overload
|
|
@@ -330,6 +375,7 @@ class ClientBase(ABC):
|
|
|
330
375
|
headers_model: BaseModel | None = None,
|
|
331
376
|
response_model: str,
|
|
332
377
|
error_model: type[Any] | None = None,
|
|
378
|
+
response_headers_model: type[Any] | None = None,
|
|
333
379
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
334
380
|
|
|
335
381
|
@overload
|
|
@@ -344,6 +390,7 @@ class ClientBase(ABC):
|
|
|
344
390
|
headers_model: BaseModel | None = None,
|
|
345
391
|
response_model: type[T],
|
|
346
392
|
error_model: type[Any] | None = None,
|
|
393
|
+
response_headers_model: type[Any] | None = None,
|
|
347
394
|
) -> ApiResponse[T]: ...
|
|
348
395
|
|
|
349
396
|
@overload
|
|
@@ -358,6 +405,7 @@ class ClientBase(ABC):
|
|
|
358
405
|
headers_model: BaseModel | None = None,
|
|
359
406
|
response_model: None = None,
|
|
360
407
|
error_model: type[Any] | None = None,
|
|
408
|
+
response_headers_model: type[Any] | None = None,
|
|
361
409
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
362
410
|
|
|
363
411
|
def _validate_request_params(
|
|
@@ -521,6 +569,7 @@ class ClientBase(ABC):
|
|
|
521
569
|
headers_model: BaseModel | None = None,
|
|
522
570
|
response_model: type[T] | type[Any] | str | _NoValidation | None = None,
|
|
523
571
|
error_model: type | None = None,
|
|
572
|
+
response_headers_model: type | None = None,
|
|
524
573
|
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
525
574
|
"""Make an HTTP request and return an ApiResponse."""
|
|
526
575
|
# Validate request parameters
|
|
@@ -556,20 +605,31 @@ class ClientBase(ABC):
|
|
|
556
605
|
),
|
|
557
606
|
)
|
|
558
607
|
|
|
608
|
+
response_headers = _parse_response_headers(
|
|
609
|
+
client_response.headers, response_headers_model
|
|
610
|
+
)
|
|
611
|
+
|
|
559
612
|
# Handle error responses
|
|
560
613
|
_raise_for_error_status(
|
|
561
614
|
client_response.status,
|
|
562
615
|
client_response.body,
|
|
563
616
|
metadata,
|
|
564
617
|
error_model=error_model,
|
|
618
|
+
response_headers=response_headers,
|
|
565
619
|
)
|
|
566
620
|
|
|
567
621
|
# Validate and return response
|
|
568
622
|
if response_model is not None:
|
|
569
|
-
return _validate_body(
|
|
623
|
+
return _validate_body(
|
|
624
|
+
client_response.body, response_model, metadata, response_headers
|
|
625
|
+
)
|
|
570
626
|
else:
|
|
571
627
|
# No response_model provided - parse JSON as-is
|
|
572
|
-
return ApiResponse(
|
|
628
|
+
return ApiResponse(
|
|
629
|
+
_parse_json_body(client_response.body, metadata, response_headers),
|
|
630
|
+
metadata,
|
|
631
|
+
response_headers,
|
|
632
|
+
)
|
|
573
633
|
|
|
574
634
|
except httpx.TimeoutException as e:
|
|
575
635
|
raise TimeoutError.from_httpx_timeout(e)
|
|
@@ -587,7 +647,8 @@ class ClientBase(ABC):
|
|
|
587
647
|
headers_model: BaseModel | None = None,
|
|
588
648
|
item_model: type[T] | type[Any] | str | _NoValidation | None = None,
|
|
589
649
|
error_model: type | None = None,
|
|
590
|
-
|
|
650
|
+
response_headers_model: type | None = None,
|
|
651
|
+
) -> ApiStream[Any]:
|
|
591
652
|
"""Open an SSE stream and yield items validated against ``item_model``.
|
|
592
653
|
|
|
593
654
|
Errors raised when the server returns a non-2xx response are
|
|
@@ -595,69 +656,79 @@ class ClientBase(ABC):
|
|
|
595
656
|
problems raise ``NetworkError`` / ``TimeoutError``. Per-event
|
|
596
657
|
validation failures raise ``ValidationError``.
|
|
597
658
|
|
|
598
|
-
The
|
|
659
|
+
The request is sent when iteration starts; the returned stream's
|
|
660
|
+
``metadata`` and ``headers`` are set once the response arrives. The
|
|
661
|
+
HTTP connection is released when the stream is exhausted,
|
|
599
662
|
``close()``-d, or garbage-collected, so consumers can ``break`` out
|
|
600
663
|
of the loop without leaking sockets. Middleware is intentionally
|
|
601
664
|
bypassed because typical middleware buffers the response body.
|
|
602
665
|
"""
|
|
603
666
|
|
|
604
|
-
|
|
667
|
+
def open_items(stream: ApiStream[Any]) -> Generator[Any, None, None]:
|
|
668
|
+
self._validate_request_params(json_data, json_model)
|
|
605
669
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
start_time = time.time()
|
|
614
|
-
try:
|
|
615
|
-
response = self._client.send(request, stream=True)
|
|
616
|
-
except httpx.TimeoutException as e:
|
|
617
|
-
raise TimeoutError.from_httpx_timeout(e)
|
|
618
|
-
except httpx.RequestError as e:
|
|
619
|
-
raise NetworkError.from_httpx_error(e)
|
|
620
|
-
|
|
621
|
-
try:
|
|
622
|
-
metadata = TransportMetadata.from_response(response, start_time)
|
|
623
|
-
if response.status_code >= 400:
|
|
624
|
-
# read_full body so error parsing can see it
|
|
625
|
-
response.read()
|
|
626
|
-
_raise_for_error_status(
|
|
627
|
-
response.status_code,
|
|
628
|
-
response.content,
|
|
629
|
-
metadata,
|
|
630
|
-
error_model=error_model,
|
|
631
|
-
)
|
|
632
|
-
|
|
633
|
-
adapter: TypeAdapter[Any] | None = None
|
|
634
|
-
if (
|
|
635
|
-
item_model is not None
|
|
636
|
-
and item_model is not NO_VALIDATION
|
|
637
|
-
and item_model != "Any"
|
|
638
|
-
and item_model is not Any
|
|
639
|
-
):
|
|
640
|
-
adapter = TypeAdapter(item_model)
|
|
670
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
671
|
+
client_request = self._build_client_request(
|
|
672
|
+
path, json_data, json_model, headers_model
|
|
673
|
+
)
|
|
674
|
+
request = self._build_httpx_request(client_request, url, params)
|
|
675
|
+
request.headers["accept"] = "text/event-stream"
|
|
641
676
|
|
|
677
|
+
start_time = time.time()
|
|
642
678
|
try:
|
|
643
|
-
|
|
644
|
-
if adapter is None:
|
|
645
|
-
yield json.loads(event.data)
|
|
646
|
-
else:
|
|
647
|
-
try:
|
|
648
|
-
yield adapter.validate_json(event.data)
|
|
649
|
-
except PydanticValidationError as e:
|
|
650
|
-
raise ValidationError(
|
|
651
|
-
f"SSE event validation failed: {e}",
|
|
652
|
-
validation_errors=e.errors(),
|
|
653
|
-
cause=e,
|
|
654
|
-
)
|
|
679
|
+
response = self._client.send(request, stream=True)
|
|
655
680
|
except httpx.TimeoutException as e:
|
|
656
681
|
raise TimeoutError.from_httpx_timeout(e)
|
|
657
682
|
except httpx.RequestError as e:
|
|
658
683
|
raise NetworkError.from_httpx_error(e)
|
|
659
|
-
|
|
660
|
-
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
metadata = TransportMetadata.from_response(response, start_time)
|
|
687
|
+
response_headers = _parse_response_headers(
|
|
688
|
+
response.headers, response_headers_model
|
|
689
|
+
)
|
|
690
|
+
stream._set_response(metadata, response_headers)
|
|
691
|
+
if response.status_code >= 400:
|
|
692
|
+
# read_full body so error parsing can see it
|
|
693
|
+
response.read()
|
|
694
|
+
_raise_for_error_status(
|
|
695
|
+
response.status_code,
|
|
696
|
+
response.content,
|
|
697
|
+
metadata,
|
|
698
|
+
error_model=error_model,
|
|
699
|
+
response_headers=response_headers,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
adapter: TypeAdapter[Any] | None = None
|
|
703
|
+
if (
|
|
704
|
+
item_model is not None
|
|
705
|
+
and item_model is not NO_VALIDATION
|
|
706
|
+
and item_model != "Any"
|
|
707
|
+
and item_model is not Any
|
|
708
|
+
):
|
|
709
|
+
adapter = TypeAdapter(item_model)
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
for event in parse_sse(response.iter_lines()):
|
|
713
|
+
if adapter is None:
|
|
714
|
+
yield json.loads(event.data)
|
|
715
|
+
else:
|
|
716
|
+
try:
|
|
717
|
+
yield adapter.validate_json(event.data)
|
|
718
|
+
except PydanticValidationError as e:
|
|
719
|
+
raise ValidationError(
|
|
720
|
+
f"SSE event validation failed: {e}",
|
|
721
|
+
validation_errors=e.errors(),
|
|
722
|
+
cause=e,
|
|
723
|
+
)
|
|
724
|
+
except httpx.TimeoutException as e:
|
|
725
|
+
raise TimeoutError.from_httpx_timeout(e)
|
|
726
|
+
except httpx.RequestError as e:
|
|
727
|
+
raise NetworkError.from_httpx_error(e)
|
|
728
|
+
finally:
|
|
729
|
+
response.close()
|
|
730
|
+
|
|
731
|
+
return ApiStream(open_items)
|
|
661
732
|
|
|
662
733
|
|
|
663
734
|
class AsyncClientBase(ABC):
|
|
@@ -784,6 +855,7 @@ class AsyncClientBase(ABC):
|
|
|
784
855
|
headers_model: BaseModel | None = None,
|
|
785
856
|
response_model: type[T],
|
|
786
857
|
error_model: type[Any] | None = None,
|
|
858
|
+
response_headers_model: type[Any] | None = None,
|
|
787
859
|
) -> ApiResponse[T]: ...
|
|
788
860
|
|
|
789
861
|
@overload
|
|
@@ -798,6 +870,7 @@ class AsyncClientBase(ABC):
|
|
|
798
870
|
headers_model: BaseModel | None = None,
|
|
799
871
|
response_model: None = None,
|
|
800
872
|
error_model: type[Any] | None = None,
|
|
873
|
+
response_headers_model: type[Any] | None = None,
|
|
801
874
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
802
875
|
|
|
803
876
|
@overload
|
|
@@ -812,6 +885,7 @@ class AsyncClientBase(ABC):
|
|
|
812
885
|
headers_model: BaseModel | None = None,
|
|
813
886
|
response_model: type[Any],
|
|
814
887
|
error_model: type[Any] | None = None,
|
|
888
|
+
response_headers_model: type[Any] | None = None,
|
|
815
889
|
) -> ApiResponse[Any]: ...
|
|
816
890
|
|
|
817
891
|
@overload
|
|
@@ -826,6 +900,7 @@ class AsyncClientBase(ABC):
|
|
|
826
900
|
headers_model: BaseModel | None = None,
|
|
827
901
|
response_model: str,
|
|
828
902
|
error_model: type[Any] | None = None,
|
|
903
|
+
response_headers_model: type[Any] | None = None,
|
|
829
904
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
830
905
|
|
|
831
906
|
@overload
|
|
@@ -840,6 +915,7 @@ class AsyncClientBase(ABC):
|
|
|
840
915
|
headers_model: BaseModel | None = None,
|
|
841
916
|
response_model: type[T],
|
|
842
917
|
error_model: type[Any] | None = None,
|
|
918
|
+
response_headers_model: type[Any] | None = None,
|
|
843
919
|
) -> ApiResponse[T]: ...
|
|
844
920
|
|
|
845
921
|
@overload
|
|
@@ -854,6 +930,7 @@ class AsyncClientBase(ABC):
|
|
|
854
930
|
headers_model: BaseModel | None = None,
|
|
855
931
|
response_model: None = None,
|
|
856
932
|
error_model: type[Any] | None = None,
|
|
933
|
+
response_headers_model: type[Any] | None = None,
|
|
857
934
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
858
935
|
|
|
859
936
|
def _validate_request_params(
|
|
@@ -1013,6 +1090,7 @@ class AsyncClientBase(ABC):
|
|
|
1013
1090
|
headers_model: BaseModel | None = None,
|
|
1014
1091
|
response_model: type[T] | type[Any] | str | _NoValidation | None = None,
|
|
1015
1092
|
error_model: type | None = None,
|
|
1093
|
+
response_headers_model: type | None = None,
|
|
1016
1094
|
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
1017
1095
|
"""Make an HTTP request and return an ApiResponse."""
|
|
1018
1096
|
# Validate request parameters
|
|
@@ -1048,26 +1126,37 @@ class AsyncClientBase(ABC):
|
|
|
1048
1126
|
),
|
|
1049
1127
|
)
|
|
1050
1128
|
|
|
1129
|
+
response_headers = _parse_response_headers(
|
|
1130
|
+
client_response.headers, response_headers_model
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1051
1133
|
# Handle error responses
|
|
1052
1134
|
_raise_for_error_status(
|
|
1053
1135
|
client_response.status,
|
|
1054
1136
|
client_response.body,
|
|
1055
1137
|
metadata,
|
|
1056
1138
|
error_model=error_model,
|
|
1139
|
+
response_headers=response_headers,
|
|
1057
1140
|
)
|
|
1058
1141
|
|
|
1059
1142
|
# Validate and return response
|
|
1060
1143
|
if response_model is not None:
|
|
1061
|
-
return _validate_body(
|
|
1144
|
+
return _validate_body(
|
|
1145
|
+
client_response.body, response_model, metadata, response_headers
|
|
1146
|
+
)
|
|
1062
1147
|
else:
|
|
1063
|
-
return ApiResponse(
|
|
1148
|
+
return ApiResponse(
|
|
1149
|
+
_parse_json_body(client_response.body, metadata, response_headers),
|
|
1150
|
+
metadata,
|
|
1151
|
+
response_headers,
|
|
1152
|
+
)
|
|
1064
1153
|
|
|
1065
1154
|
except httpx.TimeoutException as e:
|
|
1066
1155
|
raise TimeoutError.from_httpx_timeout(e)
|
|
1067
1156
|
except httpx.RequestError as e:
|
|
1068
1157
|
raise NetworkError.from_httpx_error(e)
|
|
1069
1158
|
|
|
1070
|
-
|
|
1159
|
+
def _make_sse_request(
|
|
1071
1160
|
self,
|
|
1072
1161
|
|
|
1073
1162
|
path: str,
|
|
@@ -1078,7 +1167,8 @@ class AsyncClientBase(ABC):
|
|
|
1078
1167
|
headers_model: BaseModel | None = None,
|
|
1079
1168
|
item_model: type[T] | type[Any] | str | _NoValidation | None = None,
|
|
1080
1169
|
error_model: type | None = None,
|
|
1081
|
-
|
|
1170
|
+
response_headers_model: type | None = None,
|
|
1171
|
+
) -> AsyncApiStream[Any]:
|
|
1082
1172
|
"""Open an SSE stream and yield items validated against ``item_model``.
|
|
1083
1173
|
|
|
1084
1174
|
Errors raised when the server returns a non-2xx response are
|
|
@@ -1086,65 +1176,78 @@ class AsyncClientBase(ABC):
|
|
|
1086
1176
|
problems raise ``NetworkError`` / ``TimeoutError``. Per-event
|
|
1087
1177
|
validation failures raise ``ValidationError``.
|
|
1088
1178
|
|
|
1089
|
-
The
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1179
|
+
The request is sent when iteration starts; the returned stream's
|
|
1180
|
+
``metadata`` and ``headers`` are set once the response arrives. The
|
|
1181
|
+
HTTP connection is released when the stream is exhausted or
|
|
1182
|
+
``aclose()``-d, so consumers can ``break`` out of the loop without
|
|
1183
|
+
leaking sockets. Middleware is intentionally bypassed because
|
|
1184
|
+
typical middleware buffers the response body.
|
|
1093
1185
|
"""
|
|
1094
1186
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
path, json_data, json_model, headers_model
|
|
1100
|
-
)
|
|
1101
|
-
request = self._build_httpx_request(client_request, url, params)
|
|
1102
|
-
request.headers["accept"] = "text/event-stream"
|
|
1103
|
-
|
|
1104
|
-
start_time = time.time()
|
|
1105
|
-
try:
|
|
1106
|
-
response = await self._client.send(request, stream=True)
|
|
1107
|
-
except httpx.TimeoutException as e:
|
|
1108
|
-
raise TimeoutError.from_httpx_timeout(e)
|
|
1109
|
-
except httpx.RequestError as e:
|
|
1110
|
-
raise NetworkError.from_httpx_error(e)
|
|
1187
|
+
async def open_items(
|
|
1188
|
+
stream: AsyncApiStream[Any],
|
|
1189
|
+
) -> AsyncGenerator[Any, None]:
|
|
1190
|
+
self._validate_request_params(json_data, json_model)
|
|
1111
1191
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
response.content,
|
|
1119
|
-
metadata,
|
|
1120
|
-
error_model=error_model,
|
|
1121
|
-
)
|
|
1122
|
-
|
|
1123
|
-
adapter: TypeAdapter[Any] | None = None
|
|
1124
|
-
if (
|
|
1125
|
-
item_model is not None
|
|
1126
|
-
and item_model is not NO_VALIDATION
|
|
1127
|
-
and item_model != "Any"
|
|
1128
|
-
and item_model is not Any
|
|
1129
|
-
):
|
|
1130
|
-
adapter = TypeAdapter(item_model)
|
|
1192
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
1193
|
+
client_request = self._build_client_request(
|
|
1194
|
+
path, json_data, json_model, headers_model
|
|
1195
|
+
)
|
|
1196
|
+
request = self._build_httpx_request(client_request, url, params)
|
|
1197
|
+
request.headers["accept"] = "text/event-stream"
|
|
1131
1198
|
|
|
1199
|
+
start_time = time.time()
|
|
1132
1200
|
try:
|
|
1133
|
-
|
|
1134
|
-
if adapter is None:
|
|
1135
|
-
yield json.loads(event.data)
|
|
1136
|
-
else:
|
|
1137
|
-
try:
|
|
1138
|
-
yield adapter.validate_json(event.data)
|
|
1139
|
-
except PydanticValidationError as e:
|
|
1140
|
-
raise ValidationError(
|
|
1141
|
-
f"SSE event validation failed: {e}",
|
|
1142
|
-
validation_errors=e.errors(),
|
|
1143
|
-
cause=e,
|
|
1144
|
-
)
|
|
1201
|
+
response = await self._client.send(request, stream=True)
|
|
1145
1202
|
except httpx.TimeoutException as e:
|
|
1146
1203
|
raise TimeoutError.from_httpx_timeout(e)
|
|
1147
1204
|
except httpx.RequestError as e:
|
|
1148
1205
|
raise NetworkError.from_httpx_error(e)
|
|
1149
|
-
|
|
1150
|
-
|
|
1206
|
+
|
|
1207
|
+
try:
|
|
1208
|
+
metadata = TransportMetadata.from_response(response, start_time)
|
|
1209
|
+
response_headers = _parse_response_headers(
|
|
1210
|
+
response.headers, response_headers_model
|
|
1211
|
+
)
|
|
1212
|
+
stream._set_response(metadata, response_headers)
|
|
1213
|
+
if response.status_code >= 400:
|
|
1214
|
+
await response.aread()
|
|
1215
|
+
_raise_for_error_status(
|
|
1216
|
+
response.status_code,
|
|
1217
|
+
response.content,
|
|
1218
|
+
metadata,
|
|
1219
|
+
error_model=error_model,
|
|
1220
|
+
response_headers=response_headers,
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
adapter: TypeAdapter[Any] | None = None
|
|
1224
|
+
if (
|
|
1225
|
+
item_model is not None
|
|
1226
|
+
and item_model is not NO_VALIDATION
|
|
1227
|
+
and item_model != "Any"
|
|
1228
|
+
and item_model is not Any
|
|
1229
|
+
):
|
|
1230
|
+
adapter = TypeAdapter(item_model)
|
|
1231
|
+
|
|
1232
|
+
try:
|
|
1233
|
+
async for event in aparse_sse(response.aiter_lines()):
|
|
1234
|
+
if adapter is None:
|
|
1235
|
+
yield json.loads(event.data)
|
|
1236
|
+
else:
|
|
1237
|
+
try:
|
|
1238
|
+
yield adapter.validate_json(event.data)
|
|
1239
|
+
except PydanticValidationError as e:
|
|
1240
|
+
raise ValidationError(
|
|
1241
|
+
f"SSE event validation failed: {e}",
|
|
1242
|
+
validation_errors=e.errors(),
|
|
1243
|
+
cause=e,
|
|
1244
|
+
)
|
|
1245
|
+
except httpx.TimeoutException as e:
|
|
1246
|
+
raise TimeoutError.from_httpx_timeout(e)
|
|
1247
|
+
except httpx.RequestError as e:
|
|
1248
|
+
raise NetworkError.from_httpx_error(e)
|
|
1249
|
+
finally:
|
|
1250
|
+
await response.aclose()
|
|
1251
|
+
|
|
1252
|
+
return AsyncApiStream(open_items)
|
|
1253
|
+
|
{reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/exceptions.py
RENAMED
|
@@ -22,10 +22,15 @@ class ApiError(Exception):
|
|
|
22
22
|
*,
|
|
23
23
|
metadata: TransportMetadata | None = None,
|
|
24
24
|
cause: Exception | None = None,
|
|
25
|
+
headers: Any | None = None,
|
|
25
26
|
) -> None:
|
|
26
27
|
super().__init__(message)
|
|
27
28
|
self.metadata = metadata
|
|
28
29
|
self.cause = cause
|
|
30
|
+
# The API's declared response headers, validated into the generated
|
|
31
|
+
# headers model; None if the API declares none or no response arrived.
|
|
32
|
+
# All headers, untyped, are in ``metadata.headers``.
|
|
33
|
+
self.headers = headers
|
|
29
34
|
|
|
30
35
|
@property
|
|
31
36
|
def status_code(self) -> int | None:
|
|
@@ -79,8 +84,9 @@ class ApplicationError(ApiError):
|
|
|
79
84
|
metadata: TransportMetadata,
|
|
80
85
|
error_data: Any | None = None,
|
|
81
86
|
typed_error: Any | None = None,
|
|
87
|
+
headers: Any | None = None,
|
|
82
88
|
) -> None:
|
|
83
|
-
super().__init__(message, metadata=metadata)
|
|
89
|
+
super().__init__(message, metadata=metadata, headers=headers)
|
|
84
90
|
self.error_data = error_data
|
|
85
91
|
self.typed_error = typed_error
|
|
86
92
|
|
|
@@ -124,6 +130,8 @@ class ValidationError(ApiError):
|
|
|
124
130
|
*,
|
|
125
131
|
validation_errors: list[Any] | None = None,
|
|
126
132
|
cause: Exception | None = None,
|
|
133
|
+
metadata: TransportMetadata | None = None,
|
|
134
|
+
headers: Any | None = None,
|
|
127
135
|
) -> None:
|
|
128
|
-
super().__init__(message, cause=cause)
|
|
136
|
+
super().__init__(message, metadata=metadata, cause=cause, headers=headers)
|
|
129
137
|
self.validation_errors = validation_errors or []
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import time
|
|
6
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Iterator
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any, Generic
|
|
8
9
|
|
|
@@ -11,6 +12,11 @@ from typing_extensions import TypeVar
|
|
|
11
12
|
|
|
12
13
|
T = TypeVar("T", covariant=True)
|
|
13
14
|
E = TypeVar("E", covariant=True, default=Any)
|
|
15
|
+
H = TypeVar("H", covariant=True, default=Any)
|
|
16
|
+
# Streams record the response after construction, so their parameters
|
|
17
|
+
# appear in setter arguments and can't be covariant.
|
|
18
|
+
Item = TypeVar("Item")
|
|
19
|
+
StreamH = TypeVar("StreamH", default=Any)
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
@dataclass(frozen=True)
|
|
@@ -38,21 +44,25 @@ class TransportMetadata:
|
|
|
38
44
|
)
|
|
39
45
|
|
|
40
46
|
|
|
41
|
-
class ApiResponse(Generic[T, E]):
|
|
47
|
+
class ApiResponse(Generic[T, E, H]):
|
|
42
48
|
"""Wrapper for API responses with typed success and error values.
|
|
43
49
|
|
|
44
50
|
Type parameters:
|
|
45
51
|
T: The success response type.
|
|
46
52
|
E: The error response type (defaults to Any when not specified).
|
|
53
|
+
H: The API's declared response headers model (defaults to Any).
|
|
47
54
|
|
|
48
55
|
Provides ergonomic access to both the deserialized value and transport metadata.
|
|
49
56
|
Supports `ApiResponse[OutputType]` (backward compatible) and
|
|
50
57
|
`ApiResponse[OutputType, ErrorType]` (with typed errors).
|
|
51
58
|
"""
|
|
52
59
|
|
|
53
|
-
def __init__(
|
|
60
|
+
def __init__(
|
|
61
|
+
self, value: T, metadata: TransportMetadata, headers: H | None = None
|
|
62
|
+
) -> None:
|
|
54
63
|
self._value = value
|
|
55
64
|
self._metadata = metadata
|
|
65
|
+
self._headers = headers
|
|
56
66
|
|
|
57
67
|
@property
|
|
58
68
|
def value(self) -> T:
|
|
@@ -64,6 +74,15 @@ class ApiResponse(Generic[T, E]):
|
|
|
64
74
|
"""Transport metadata including timing, headers, and status."""
|
|
65
75
|
return self._metadata
|
|
66
76
|
|
|
77
|
+
@property
|
|
78
|
+
def headers(self) -> H | None:
|
|
79
|
+
"""The API's declared response headers, validated into its headers model.
|
|
80
|
+
|
|
81
|
+
``None`` if the API declares no response headers. All headers,
|
|
82
|
+
untyped, are in ``metadata.headers``.
|
|
83
|
+
"""
|
|
84
|
+
return self._headers
|
|
85
|
+
|
|
67
86
|
@property
|
|
68
87
|
def data(self) -> T:
|
|
69
88
|
"""Alias for value for ergonomic access (useful when the payload is a dict)."""
|
|
@@ -79,7 +98,7 @@ class ApiResponse(Generic[T, E]):
|
|
|
79
98
|
List of available attributes from both wrapper and value.
|
|
80
99
|
"""
|
|
81
100
|
# Get ApiResponse's own attributes
|
|
82
|
-
wrapper_attrs = ["value", "metadata", "data"]
|
|
101
|
+
wrapper_attrs = ["value", "metadata", "headers", "data"]
|
|
83
102
|
|
|
84
103
|
# Get attributes from the wrapped value
|
|
85
104
|
value_attrs = []
|
|
@@ -134,3 +153,77 @@ class ApiResponse(Generic[T, E]):
|
|
|
134
153
|
return (
|
|
135
154
|
f"ApiResponse(value={self._value!r}, status={self._metadata.status_code})"
|
|
136
155
|
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ApiStream(Iterator[Item], Generic[Item, StreamH]):
|
|
159
|
+
"""Items of a streaming endpoint, with the HTTP response they came from.
|
|
160
|
+
|
|
161
|
+
The request is sent when iteration starts, so ``metadata`` and
|
|
162
|
+
``headers`` are ``None`` until the first item is requested. ``headers``
|
|
163
|
+
holds the API's declared response headers, validated into its headers
|
|
164
|
+
model; all headers, untyped, are in ``metadata.headers``.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
open_items: Callable[[ApiStream[Item, StreamH]], Generator[Item, None, None]],
|
|
170
|
+
) -> None:
|
|
171
|
+
self._metadata: TransportMetadata | None = None
|
|
172
|
+
self._headers: StreamH | None = None
|
|
173
|
+
self._items = open_items(self)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def metadata(self) -> TransportMetadata | None:
|
|
177
|
+
return self._metadata
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def headers(self) -> StreamH | None:
|
|
181
|
+
return self._headers
|
|
182
|
+
|
|
183
|
+
def _set_response(
|
|
184
|
+
self, metadata: TransportMetadata, headers: StreamH | None
|
|
185
|
+
) -> None:
|
|
186
|
+
self._metadata = metadata
|
|
187
|
+
self._headers = headers
|
|
188
|
+
|
|
189
|
+
def __next__(self) -> Item:
|
|
190
|
+
return next(self._items)
|
|
191
|
+
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
"""Release the connection without reading the rest of the stream."""
|
|
194
|
+
self._items.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class AsyncApiStream(AsyncIterator[Item], Generic[Item, StreamH]):
|
|
198
|
+
"""Async counterpart of :class:`ApiStream`."""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
open_items: Callable[
|
|
203
|
+
[AsyncApiStream[Item, StreamH]], AsyncGenerator[Item, None]
|
|
204
|
+
],
|
|
205
|
+
) -> None:
|
|
206
|
+
self._metadata: TransportMetadata | None = None
|
|
207
|
+
self._headers: StreamH | None = None
|
|
208
|
+
self._items = open_items(self)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def metadata(self) -> TransportMetadata | None:
|
|
212
|
+
return self._metadata
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def headers(self) -> StreamH | None:
|
|
216
|
+
return self._headers
|
|
217
|
+
|
|
218
|
+
def _set_response(
|
|
219
|
+
self, metadata: TransportMetadata, headers: StreamH | None
|
|
220
|
+
) -> None:
|
|
221
|
+
self._metadata = metadata
|
|
222
|
+
self._headers = headers
|
|
223
|
+
|
|
224
|
+
async def __anext__(self) -> Item:
|
|
225
|
+
return await self._items.__anext__()
|
|
226
|
+
|
|
227
|
+
async def aclose(self) -> None:
|
|
228
|
+
"""Release the connection without reading the rest of the stream."""
|
|
229
|
+
await self._items.aclose()
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Tests for the client base classes."""
|
|
2
2
|
|
|
3
|
+
import datetime
|
|
3
4
|
from unittest.mock import AsyncMock, Mock
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
6
7
|
import pytest
|
|
7
|
-
from pydantic import BaseModel
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
9
|
|
|
9
10
|
from reflectapi_runtime import (
|
|
10
11
|
ApiResponse,
|
|
@@ -1173,3 +1174,137 @@ class TestParsingBypassesSyntheticHttpxResponse:
|
|
|
1173
1174
|
len(body)
|
|
1174
1175
|
)
|
|
1175
1176
|
assert result.metadata.headers.get("content-length") == "9999"
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
class SampleResponseHeaders(BaseModel):
|
|
1180
|
+
"""Shaped like a generated response headers model."""
|
|
1181
|
+
|
|
1182
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
1183
|
+
|
|
1184
|
+
retry_after: str | None = Field(default=None, validation_alias="retry-after")
|
|
1185
|
+
request_id: str | None = Field(default=None, validation_alias="x-request-id")
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
class FixedResponseClient:
|
|
1189
|
+
def __init__(self, status: int, headers: dict[str, str], body: bytes) -> None:
|
|
1190
|
+
self.response = Response(
|
|
1191
|
+
status=status, headers=httpx.Headers(headers), body=body
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
def request(self, request: Request) -> Response:
|
|
1195
|
+
return self.response
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
class AsyncFixedResponseClient(FixedResponseClient):
|
|
1199
|
+
async def request(self, request: Request) -> Response: # type: ignore[override]
|
|
1200
|
+
return self.response
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
class TestResponseHeaders:
|
|
1204
|
+
def test_declared_headers_on_success(self):
|
|
1205
|
+
transport = FixedResponseClient(
|
|
1206
|
+
200,
|
|
1207
|
+
{"X-Request-ID": "req-1", "cf-ray": "abc"},
|
|
1208
|
+
b'{"name":"test","age":25}',
|
|
1209
|
+
)
|
|
1210
|
+
client = ClientBase("http://example.com", client=transport)
|
|
1211
|
+
|
|
1212
|
+
result = client._make_request(
|
|
1213
|
+
"/test",
|
|
1214
|
+
response_model=SampleModel,
|
|
1215
|
+
response_headers_model=SampleResponseHeaders,
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
assert result.headers == SampleResponseHeaders(request_id="req-1")
|
|
1219
|
+
assert result.metadata.headers["cf-ray"] == "abc"
|
|
1220
|
+
|
|
1221
|
+
def test_declared_headers_on_application_error(self):
|
|
1222
|
+
transport = FixedResponseClient(429, {"retry-after": "7"}, b"slow down")
|
|
1223
|
+
client = ClientBase("http://example.com", client=transport)
|
|
1224
|
+
|
|
1225
|
+
with pytest.raises(ApplicationError) as exc_info:
|
|
1226
|
+
client._make_request(
|
|
1227
|
+
"/test",
|
|
1228
|
+
response_model=SampleModel,
|
|
1229
|
+
response_headers_model=SampleResponseHeaders,
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
assert exc_info.value.headers == SampleResponseHeaders(retry_after="7")
|
|
1233
|
+
|
|
1234
|
+
def test_declared_headers_on_invalid_success_body(self):
|
|
1235
|
+
transport = FixedResponseClient(200, {"x-request-id": "req-2"}, b'{"name":1}')
|
|
1236
|
+
client = ClientBase("http://example.com", client=transport)
|
|
1237
|
+
|
|
1238
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
1239
|
+
client._make_request(
|
|
1240
|
+
"/test",
|
|
1241
|
+
response_model=SampleModel,
|
|
1242
|
+
response_headers_model=SampleResponseHeaders,
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
assert exc_info.value.status_code == 200
|
|
1246
|
+
assert exc_info.value.headers == SampleResponseHeaders(request_id="req-2")
|
|
1247
|
+
|
|
1248
|
+
def test_malformed_typed_header_reads_as_absent(self):
|
|
1249
|
+
class TypedHeaders(BaseModel):
|
|
1250
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
1251
|
+
|
|
1252
|
+
expires_at: datetime.datetime | None = Field(
|
|
1253
|
+
default=None, validation_alias="x-expires-at"
|
|
1254
|
+
)
|
|
1255
|
+
request_id: str | None = Field(
|
|
1256
|
+
default=None, validation_alias="x-request-id"
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
transport = FixedResponseClient(
|
|
1260
|
+
503, {"x-expires-at": "garbage", "x-request-id": "req-3"}, b"unavailable"
|
|
1261
|
+
)
|
|
1262
|
+
client = ClientBase("http://example.com", client=transport)
|
|
1263
|
+
|
|
1264
|
+
with pytest.raises(ApplicationError) as exc_info:
|
|
1265
|
+
client._make_request(
|
|
1266
|
+
"/test",
|
|
1267
|
+
response_model=SampleModel,
|
|
1268
|
+
response_headers_model=TypedHeaders,
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
assert exc_info.value.status_code == 503
|
|
1272
|
+
assert exc_info.value.headers == TypedHeaders(request_id="req-3")
|
|
1273
|
+
assert exc_info.value.metadata.headers["x-expires-at"] == "garbage"
|
|
1274
|
+
|
|
1275
|
+
def test_no_declared_headers(self):
|
|
1276
|
+
client = ClientBase("http://example.com", client=ShapeClient())
|
|
1277
|
+
|
|
1278
|
+
result = client._make_request("/test", response_model=SampleModel)
|
|
1279
|
+
|
|
1280
|
+
assert result.headers is None
|
|
1281
|
+
|
|
1282
|
+
@pytest.mark.asyncio
|
|
1283
|
+
async def test_declared_headers_on_async_success(self):
|
|
1284
|
+
transport = AsyncFixedResponseClient(
|
|
1285
|
+
200, {"retry-after": "3"}, b'{"name":"test","age":25}'
|
|
1286
|
+
)
|
|
1287
|
+
client = AsyncClientBase("http://example.com", client=transport)
|
|
1288
|
+
|
|
1289
|
+
result = await client._make_request(
|
|
1290
|
+
"/test",
|
|
1291
|
+
response_model=SampleModel,
|
|
1292
|
+
response_headers_model=SampleResponseHeaders,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
assert result.headers == SampleResponseHeaders(retry_after="3")
|
|
1296
|
+
|
|
1297
|
+
@pytest.mark.asyncio
|
|
1298
|
+
async def test_declared_headers_on_async_application_error(self):
|
|
1299
|
+
transport = AsyncFixedResponseClient(503, {"retry-after": "30"}, b"unavailable")
|
|
1300
|
+
client = AsyncClientBase("http://example.com", client=transport)
|
|
1301
|
+
|
|
1302
|
+
with pytest.raises(ApplicationError) as exc_info:
|
|
1303
|
+
await client._make_request(
|
|
1304
|
+
"/test",
|
|
1305
|
+
response_model=SampleModel,
|
|
1306
|
+
response_headers_model=SampleResponseHeaders,
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
assert exc_info.value.status_code == 503
|
|
1310
|
+
assert exc_info.value.headers == SampleResponseHeaders(retry_after="30")
|
|
@@ -10,7 +10,7 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
import pytest
|
|
13
|
-
from pydantic import BaseModel
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
14
|
|
|
15
15
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
16
16
|
|
|
@@ -130,6 +130,12 @@ class _Pet(BaseModel):
|
|
|
130
130
|
weight: float
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
class _StreamHeaders(BaseModel):
|
|
134
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
135
|
+
|
|
136
|
+
request_id: str | None = Field(default=None, validation_alias="x-request-id")
|
|
137
|
+
|
|
138
|
+
|
|
133
139
|
class _SyncDemo(ClientBase):
|
|
134
140
|
"""Minimal concrete subclass — exposes _make_sse_request as a public method."""
|
|
135
141
|
|
|
@@ -138,6 +144,11 @@ class _SyncDemo(ClientBase):
|
|
|
138
144
|
"pets", item_model=_Pet, error_model=None
|
|
139
145
|
)
|
|
140
146
|
|
|
147
|
+
def stream_pets_with_headers(self) -> Any:
|
|
148
|
+
return self._make_sse_request(
|
|
149
|
+
"pets", item_model=_Pet, response_headers_model=_StreamHeaders
|
|
150
|
+
)
|
|
151
|
+
|
|
141
152
|
|
|
142
153
|
class _AsyncDemo(AsyncClientBase):
|
|
143
154
|
def stream_pets(self) -> Any:
|
|
@@ -145,6 +156,11 @@ class _AsyncDemo(AsyncClientBase):
|
|
|
145
156
|
"pets", item_model=_Pet, error_model=None
|
|
146
157
|
)
|
|
147
158
|
|
|
159
|
+
def stream_pets_with_headers(self) -> Any:
|
|
160
|
+
return self._make_sse_request(
|
|
161
|
+
"pets", item_model=_Pet, response_headers_model=_StreamHeaders
|
|
162
|
+
)
|
|
163
|
+
|
|
148
164
|
|
|
149
165
|
def _sse_body(items: list[dict]) -> bytes:
|
|
150
166
|
return ("".join(f"data: {json.dumps(it)}\n\n" for it in items)).encode()
|
|
@@ -349,3 +365,36 @@ def event_loop(): # pragma: no cover - pytest-asyncio compatibility shim
|
|
|
349
365
|
loop = asyncio.new_event_loop()
|
|
350
366
|
yield loop
|
|
351
367
|
loop.close()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_sync_stream_exposes_response_once_iteration_starts() -> None:
|
|
371
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
372
|
+
body = _sse_body([{"name": "fido", "weight": 1.5}])
|
|
373
|
+
return httpx.Response(
|
|
374
|
+
200, content=body, headers={"X-Request-ID": "req-1"}
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
client = _make_sync_client(httpx.MockTransport(handler))
|
|
378
|
+
stream = client.stream_pets_with_headers()
|
|
379
|
+
assert stream.headers is None and stream.metadata is None
|
|
380
|
+
assert next(stream) == _Pet(name="fido", weight=1.5)
|
|
381
|
+
assert stream.metadata.status_code == 200
|
|
382
|
+
assert stream.headers == _StreamHeaders(request_id="req-1")
|
|
383
|
+
stream.close()
|
|
384
|
+
client.close()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@pytest.mark.asyncio
|
|
388
|
+
async def test_async_stream_init_error_carries_headers() -> None:
|
|
389
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
390
|
+
return httpx.Response(
|
|
391
|
+
429, text="slow down", headers={"x-request-id": "req-2"}
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
client = _make_async_client(httpx.MockTransport(handler))
|
|
395
|
+
stream = client.stream_pets_with_headers()
|
|
396
|
+
with pytest.raises(ApplicationError) as ei:
|
|
397
|
+
await stream.__anext__()
|
|
398
|
+
assert ei.value.headers == _StreamHeaders(request_id="req-2")
|
|
399
|
+
assert stream.headers == _StreamHeaders(request_id="req-2")
|
|
400
|
+
await client.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_pydantic_serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|