reflectapi-runtime 0.20.0__tar.gz → 0.21.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.20.0 → reflectapi_runtime-0.21.0}/PKG-INFO +13 -5
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/README.md +12 -4
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/pyproject.toml +1 -1
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/__init__.py +1 -1
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/client.py +35 -37
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/partial.py +7 -5
- reflectapi_runtime-0.21.0/src/reflectapi_runtime/py.typed +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_codegen_regressions.py +4 -5
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_partial.py +22 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_pydantic_serialization.py +4 -2
- reflectapi_runtime-0.21.0/tests/test_request_field_presence.py +136 -0
- reflectapi_runtime-0.21.0/tests/test_response_header_presence.py +90 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/.gitignore +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/auth.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/batch.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/duration.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/exceptions.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/middleware.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/response.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/serde.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/sse.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/streaming.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/testing.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/transport.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/types.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/__init__.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_auth.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_auth_negative_cases.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_batch.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_client.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_edge_cases.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_enhanced_features.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_enum_forward_compatibility.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_exceptions.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_middleware.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_response.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_sse.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_streaming.py +0 -0
- {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.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.21.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
|
|
@@ -60,14 +60,22 @@ directly.
|
|
|
60
60
|
- `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
|
|
61
61
|
exceptions raised by the generated methods on non-2xx, transport, and
|
|
62
62
|
validation failures respectively.
|
|
63
|
-
- `ReflectapiPartialModel` — base class for generated models with
|
|
64
|
-
fields
|
|
65
|
-
|
|
63
|
+
- `ReflectapiPartialModel` — base class for generated models with omittable
|
|
64
|
+
fields. Unset fields stay absent during direct serialization; explicit null
|
|
65
|
+
and values remain set, tracked via Pydantic's `model_fields_set`.
|
|
66
66
|
- Authentication helpers (`BearerTokenAuth`, `APIKeyAuth`, `BasicAuth`,
|
|
67
67
|
`OAuth2ClientCredentialsAuth`, `OAuth2AuthorizationCodeAuth`).
|
|
68
68
|
- Middleware, batching, and testing utilities (`MockClient`,
|
|
69
69
|
`CassetteMiddleware`).
|
|
70
70
|
|
|
71
|
+
## Request field presence
|
|
72
|
+
|
|
73
|
+
Requests serialize Pydantic models with wire aliases and `exclude_unset=True`,
|
|
74
|
+
recursively. A field omitted during construction is absent from JSON; a field
|
|
75
|
+
explicitly set to `None` is sent as `null`. This also applies to nested models
|
|
76
|
+
and models inside list or dictionary bodies. Unset non-null defaults are
|
|
77
|
+
omitted too: pass the default value explicitly if the server should receive it.
|
|
78
|
+
|
|
71
79
|
## Streaming endpoints
|
|
72
80
|
|
|
73
81
|
Endpoints declared with `Builder::stream_route` on the server are exposed on
|
|
@@ -93,7 +101,7 @@ mid-stream and terminates the iterator. Earlier items remain delivered.
|
|
|
93
101
|
|
|
94
102
|
## Compatibility
|
|
95
103
|
|
|
96
|
-
Python 3.12+. SDKs generated with ReflectAPI 0.
|
|
104
|
+
Python 3.12+. SDKs generated with ReflectAPI 0.21 require the 0.21 runtime.
|
|
97
105
|
Pydantic 2.10 or newer supports the generated generic models; Python 3.14
|
|
98
106
|
requires Pydantic 2.12 or newer. Installing the runtime resolves these requirements
|
|
99
107
|
and the supported HTTP dependencies automatically.
|
|
@@ -19,14 +19,22 @@ directly.
|
|
|
19
19
|
- `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
|
|
20
20
|
exceptions raised by the generated methods on non-2xx, transport, and
|
|
21
21
|
validation failures respectively.
|
|
22
|
-
- `ReflectapiPartialModel` — base class for generated models with
|
|
23
|
-
fields
|
|
24
|
-
|
|
22
|
+
- `ReflectapiPartialModel` — base class for generated models with omittable
|
|
23
|
+
fields. Unset fields stay absent during direct serialization; explicit null
|
|
24
|
+
and values remain set, tracked via Pydantic's `model_fields_set`.
|
|
25
25
|
- Authentication helpers (`BearerTokenAuth`, `APIKeyAuth`, `BasicAuth`,
|
|
26
26
|
`OAuth2ClientCredentialsAuth`, `OAuth2AuthorizationCodeAuth`).
|
|
27
27
|
- Middleware, batching, and testing utilities (`MockClient`,
|
|
28
28
|
`CassetteMiddleware`).
|
|
29
29
|
|
|
30
|
+
## Request field presence
|
|
31
|
+
|
|
32
|
+
Requests serialize Pydantic models with wire aliases and `exclude_unset=True`,
|
|
33
|
+
recursively. A field omitted during construction is absent from JSON; a field
|
|
34
|
+
explicitly set to `None` is sent as `null`. This also applies to nested models
|
|
35
|
+
and models inside list or dictionary bodies. Unset non-null defaults are
|
|
36
|
+
omitted too: pass the default value explicitly if the server should receive it.
|
|
37
|
+
|
|
30
38
|
## Streaming endpoints
|
|
31
39
|
|
|
32
40
|
Endpoints declared with `Builder::stream_route` on the server are exposed on
|
|
@@ -52,7 +60,7 @@ mid-stream and terminates the iterator. Earlier items remain delivered.
|
|
|
52
60
|
|
|
53
61
|
## Compatibility
|
|
54
62
|
|
|
55
|
-
Python 3.12+. SDKs generated with ReflectAPI 0.
|
|
63
|
+
Python 3.12+. SDKs generated with ReflectAPI 0.21 require the 0.21 runtime.
|
|
56
64
|
Pydantic 2.10 or newer supports the generated generic models; Python 3.14
|
|
57
65
|
requires Pydantic 2.12 or newer. Installing the runtime resolves these requirements
|
|
58
66
|
and the supported HTTP dependencies automatically.
|
|
@@ -47,7 +47,7 @@ def _json_serializer(obj: Any) -> Any:
|
|
|
47
47
|
elif isinstance(obj, datetime.date):
|
|
48
48
|
return obj.isoformat()
|
|
49
49
|
elif hasattr(obj, "model_dump"):
|
|
50
|
-
return obj.model_dump(
|
|
50
|
+
return obj.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
51
51
|
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
52
52
|
|
|
53
53
|
|
|
@@ -153,13 +153,29 @@ def _parse_response_headers(headers: Any, model: type[Any] | None) -> Any | None
|
|
|
153
153
|
return None
|
|
154
154
|
items = headers.items() if headers is not None else ()
|
|
155
155
|
lowered = {str(name).lower(): str(value) for name, value in items}
|
|
156
|
+
# HTTP headers are independently optional, even when the same model has
|
|
157
|
+
# required nullable fields when used as a JSON body. Supply absent headers
|
|
158
|
+
# here rather than weakening the model's body-validation contract.
|
|
159
|
+
field_keys = {
|
|
160
|
+
name: (
|
|
161
|
+
field.validation_alias
|
|
162
|
+
if isinstance(field.validation_alias, str)
|
|
163
|
+
else field.alias or name
|
|
164
|
+
)
|
|
165
|
+
for name, field in model.model_fields.items()
|
|
166
|
+
}
|
|
167
|
+
defaults = dict.fromkeys(field_keys.values())
|
|
156
168
|
try:
|
|
157
|
-
return model.model_validate(lowered)
|
|
169
|
+
return model.model_validate(defaults | lowered)
|
|
158
170
|
except PydanticValidationError as e:
|
|
159
171
|
invalid = {error["loc"][0] for error in e.errors() if error["loc"]}
|
|
160
172
|
logger.debug("ignoring malformed response headers: %s", sorted(invalid))
|
|
173
|
+
# Pydantic can report locations using field names or validation aliases.
|
|
174
|
+
invalid.update(
|
|
175
|
+
field_keys[name] for name in tuple(invalid) if name in field_keys
|
|
176
|
+
)
|
|
161
177
|
valid = {name: value for name, value in lowered.items() if name not in invalid}
|
|
162
|
-
return model.model_validate(valid)
|
|
178
|
+
return model.model_validate(defaults | valid)
|
|
163
179
|
|
|
164
180
|
|
|
165
181
|
def _validate_body(
|
|
@@ -422,15 +438,11 @@ class ClientBase(ABC):
|
|
|
422
438
|
) -> tuple[bytes, dict[str, str]]:
|
|
423
439
|
"""Serialize the request body.
|
|
424
440
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
the wire
|
|
429
|
-
|
|
430
|
-
Plain Pydantic models use ``exclude_none=True`` so an unset
|
|
431
|
-
optional field renders as an absent key, matching the wire
|
|
432
|
-
shape produced by serde with
|
|
433
|
-
``#[serde(skip_serializing_if = "Option::is_none")]``.
|
|
441
|
+
Only fields supplied by the caller are sent, recursively through
|
|
442
|
+
nested models. This lets the server apply defaults for omitted
|
|
443
|
+
fields, while explicitly supplied defaults and ``None`` values
|
|
444
|
+
reach the wire. In particular, required nullable fields and
|
|
445
|
+
explicit nulls in partial models must serialize as JSON ``null``.
|
|
434
446
|
"""
|
|
435
447
|
# Handle primitive types (for untagged unions)
|
|
436
448
|
if not hasattr(json_model, "model_dump_json"):
|
|
@@ -439,14 +451,9 @@ class ClientBase(ABC):
|
|
|
439
451
|
).encode("utf-8")
|
|
440
452
|
return content, {"Content-Type": "application/json"}
|
|
441
453
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
content = json_model.model_dump_json(by_alias=True).encode("utf-8")
|
|
446
|
-
else:
|
|
447
|
-
content = json_model.model_dump_json(
|
|
448
|
-
by_alias=True, exclude_none=True
|
|
449
|
-
).encode("utf-8")
|
|
454
|
+
content = json_model.model_dump_json(
|
|
455
|
+
by_alias=True, exclude_unset=True
|
|
456
|
+
).encode("utf-8")
|
|
450
457
|
return content, {"Content-Type": "application/json"}
|
|
451
458
|
|
|
452
459
|
def _build_headers(
|
|
@@ -947,15 +954,11 @@ class AsyncClientBase(ABC):
|
|
|
947
954
|
) -> tuple[bytes, dict[str, str]]:
|
|
948
955
|
"""Serialize the request body.
|
|
949
956
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
the wire
|
|
954
|
-
|
|
955
|
-
Plain Pydantic models use ``exclude_none=True`` so an unset
|
|
956
|
-
optional field renders as an absent key, matching the wire
|
|
957
|
-
shape produced by serde with
|
|
958
|
-
``#[serde(skip_serializing_if = "Option::is_none")]``.
|
|
957
|
+
Only fields supplied by the caller are sent, recursively through
|
|
958
|
+
nested models. This lets the server apply defaults for omitted
|
|
959
|
+
fields, while explicitly supplied defaults and ``None`` values
|
|
960
|
+
reach the wire. In particular, required nullable fields and
|
|
961
|
+
explicit nulls in partial models must serialize as JSON ``null``.
|
|
959
962
|
"""
|
|
960
963
|
# Handle primitive types (for untagged unions)
|
|
961
964
|
if not hasattr(json_model, "model_dump_json"):
|
|
@@ -964,14 +967,9 @@ class AsyncClientBase(ABC):
|
|
|
964
967
|
).encode("utf-8")
|
|
965
968
|
return content, {"Content-Type": "application/json"}
|
|
966
969
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
content = json_model.model_dump_json(by_alias=True).encode("utf-8")
|
|
971
|
-
else:
|
|
972
|
-
content = json_model.model_dump_json(
|
|
973
|
-
by_alias=True, exclude_none=True
|
|
974
|
-
).encode("utf-8")
|
|
970
|
+
content = json_model.model_dump_json(
|
|
971
|
+
by_alias=True, exclude_unset=True
|
|
972
|
+
).encode("utf-8")
|
|
975
973
|
return content, {"Content-Type": "application/json"}
|
|
976
974
|
|
|
977
975
|
def _build_headers(
|
|
@@ -15,9 +15,9 @@ the ``reflectapi::Option<T>`` enum. The Python equivalent uses
|
|
|
15
15
|
- emits a wire payload that omits keys not in ``model_fields_set``,
|
|
16
16
|
preserving the absent-vs-null distinction round-trip.
|
|
17
17
|
|
|
18
|
-
Codegen
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
Codegen also uses this base for models with other omittable fields, so their
|
|
19
|
+
unset defaults stay absent during direct model serialization as well as client
|
|
20
|
+
requests.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
@@ -28,7 +28,7 @@ from pydantic import BaseModel, model_serializer
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
class ReflectapiPartialModel(BaseModel):
|
|
31
|
-
"""Base class for reflectapi models with
|
|
31
|
+
"""Base class for reflectapi models with omittable fields.
|
|
32
32
|
|
|
33
33
|
Wire-format guarantee: a field that was never explicitly set on the
|
|
34
34
|
instance is omitted from the serialised output entirely. A field set
|
|
@@ -70,7 +70,9 @@ class ReflectapiPartialModel(BaseModel):
|
|
|
70
70
|
# ``m.snapshot = None`` after construction and have it land on the
|
|
71
71
|
# wire.
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
# Pydantic 2.10 can resolve an inherited serializer's postponed return
|
|
74
|
+
# annotation in the subclass's module, which need not import ``Any``.
|
|
75
|
+
@model_serializer(mode="wrap", return_type=dict[str, Any])
|
|
74
76
|
def _serialize_partial(
|
|
75
77
|
self, handler: Any, info: Any | None = None
|
|
76
78
|
) -> dict[str, Any]:
|
|
File without changes
|
|
@@ -83,11 +83,10 @@ class TestDurationRoundTrip:
|
|
|
83
83
|
|
|
84
84
|
|
|
85
85
|
class TestRequestSerializationOmitsUnsetOptional:
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
absent-vs-null distinction.
|
|
86
|
+
"""Request models omit unset fields so the server can apply defaults.
|
|
87
|
+
|
|
88
|
+
Both plain and partial models preserve explicitly supplied ``None``
|
|
89
|
+
as JSON null, independently of whether a field has a default.
|
|
91
90
|
"""
|
|
92
91
|
|
|
93
92
|
def _client(self):
|
|
@@ -12,6 +12,8 @@ Contract:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import json
|
|
15
|
+
import sys
|
|
16
|
+
from types import ModuleType
|
|
15
17
|
|
|
16
18
|
import pytest
|
|
17
19
|
from pydantic import BaseModel, ValidationError
|
|
@@ -229,3 +231,23 @@ class TestEdgeCases:
|
|
|
229
231
|
"""`model_dump()` (Python dict) must also drop unset keys."""
|
|
230
232
|
item = Item(identity="x")
|
|
231
233
|
assert item.model_dump() == {"identity": "x"}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_partial_subclass_does_not_require_any_in_callers_module(monkeypatch):
|
|
237
|
+
"""Inherited serializers resolve their return type in the runtime module."""
|
|
238
|
+
caller = ModuleType("_reflectapi_partial_caller_without_any")
|
|
239
|
+
caller.ReflectapiPartialModel = ReflectapiPartialModel
|
|
240
|
+
monkeypatch.setitem(sys.modules, caller.__name__, caller)
|
|
241
|
+
exec(
|
|
242
|
+
"class Request(ReflectapiPartialModel):\n"
|
|
243
|
+
" required: str | None\n"
|
|
244
|
+
" optional: str | None = None\n",
|
|
245
|
+
caller.__dict__,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
assert "Any" not in caller.__dict__
|
|
249
|
+
request = caller.Request(required=None)
|
|
250
|
+
assert request.model_dump_json() == '{"required":null}'
|
|
251
|
+
assert caller.Request(required=None, optional=None).model_dump_json() == (
|
|
252
|
+
'{"required":null,"optional":null}'
|
|
253
|
+
)
|
{reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_pydantic_serialization.py
RENAMED
|
@@ -198,7 +198,7 @@ class TestClientBasePydanticSerialization:
|
|
|
198
198
|
assert call_args[1]["params"] == {"source": "api", "version": "v1"}
|
|
199
199
|
|
|
200
200
|
expected_json = (
|
|
201
|
-
'{"name":"Param User","age":40,"email":"param@example.com"
|
|
201
|
+
'{"name":"Param User","age":40,"email":"param@example.com"}'
|
|
202
202
|
)
|
|
203
203
|
assert call_args[1]["content"] == expected_json.encode("utf-8")
|
|
204
204
|
assert call_args[1]["headers"] == {"Content-Type": "application/json"}
|
|
@@ -336,7 +336,9 @@ class TestPydanticSerializationEdgeCases:
|
|
|
336
336
|
assert parsed_data["user"]["name"] == "Complex User"
|
|
337
337
|
assert parsed_data["user"]["age"] == 45
|
|
338
338
|
assert parsed_data["address"]["street"] == "123 Main St"
|
|
339
|
-
|
|
339
|
+
# Unset defaults are left for the server to apply, including nested models.
|
|
340
|
+
assert "country" not in parsed_data["address"]
|
|
341
|
+
assert "active" not in parsed_data["user"]
|
|
340
342
|
assert parsed_data["tags"] == ["premium", "verified"]
|
|
341
343
|
assert parsed_data["metadata"]["source"] == "test"
|
|
342
344
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Request bytes preserve supplied fields and leave omitted defaults to the server."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from reflectapi_runtime import AsyncClientBase, ClientBase, ReflectapiPartialModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NullableRequest(BaseModel):
|
|
13
|
+
required_nullable: str | None
|
|
14
|
+
optional_nullable: str | None = None
|
|
15
|
+
defaulted: int = 7
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AliasedRequest(BaseModel):
|
|
19
|
+
required_nullable: str | None = Field(alias="requiredAlias")
|
|
20
|
+
optional_nullable: str | None = Field(default=None, alias="optionalAlias")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PartialRequest(ReflectapiPartialModel):
|
|
24
|
+
required_nullable: str | None
|
|
25
|
+
optional_nullable: str | None = None
|
|
26
|
+
ordinary: AliasedRequest | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NestedRequest(BaseModel):
|
|
30
|
+
ordinary: AliasedRequest
|
|
31
|
+
items: list[AliasedRequest]
|
|
32
|
+
union: AliasedRequest | str
|
|
33
|
+
partial: PartialRequest
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def request_cases() -> list[Any]:
|
|
37
|
+
aliased = AliasedRequest(requiredAlias=None, optionalAlias=None)
|
|
38
|
+
return [
|
|
39
|
+
pytest.param(
|
|
40
|
+
NullableRequest(required_nullable=None),
|
|
41
|
+
b'{"required_nullable":null}',
|
|
42
|
+
id="required-null-and-unset-optionals",
|
|
43
|
+
),
|
|
44
|
+
pytest.param(
|
|
45
|
+
NullableRequest(required_nullable=None, optional_nullable=None),
|
|
46
|
+
b'{"required_nullable":null,"optional_nullable":null}',
|
|
47
|
+
id="explicit-optional-null",
|
|
48
|
+
),
|
|
49
|
+
pytest.param(
|
|
50
|
+
NullableRequest(required_nullable="value", defaulted=7),
|
|
51
|
+
b'{"required_nullable":"value","defaulted":7}',
|
|
52
|
+
id="explicit-default-is-sent",
|
|
53
|
+
),
|
|
54
|
+
pytest.param(
|
|
55
|
+
AliasedRequest(requiredAlias=None),
|
|
56
|
+
b'{"requiredAlias":null}',
|
|
57
|
+
id="required-null-alias",
|
|
58
|
+
),
|
|
59
|
+
pytest.param(
|
|
60
|
+
aliased,
|
|
61
|
+
b'{"requiredAlias":null,"optionalAlias":null}',
|
|
62
|
+
id="explicit-null-alias",
|
|
63
|
+
),
|
|
64
|
+
pytest.param(
|
|
65
|
+
PartialRequest(required_nullable=None),
|
|
66
|
+
b'{"required_nullable":null}',
|
|
67
|
+
id="partial-unset-optionals",
|
|
68
|
+
),
|
|
69
|
+
pytest.param(
|
|
70
|
+
PartialRequest(
|
|
71
|
+
required_nullable=None,
|
|
72
|
+
optional_nullable=None,
|
|
73
|
+
ordinary=AliasedRequest(requiredAlias=None),
|
|
74
|
+
),
|
|
75
|
+
b'{"required_nullable":null,"optional_nullable":null,'
|
|
76
|
+
b'"ordinary":{"requiredAlias":null}}',
|
|
77
|
+
id="partial-with-ordinary-model",
|
|
78
|
+
),
|
|
79
|
+
pytest.param(
|
|
80
|
+
NestedRequest(
|
|
81
|
+
ordinary=aliased,
|
|
82
|
+
items=[AliasedRequest(requiredAlias=None), aliased],
|
|
83
|
+
union=aliased,
|
|
84
|
+
partial=PartialRequest(required_nullable=None, optional_nullable=None),
|
|
85
|
+
),
|
|
86
|
+
b'{"ordinary":{"requiredAlias":null,"optionalAlias":null},'
|
|
87
|
+
b'"items":[{"requiredAlias":null},'
|
|
88
|
+
b'{"requiredAlias":null,"optionalAlias":null}],'
|
|
89
|
+
b'"union":{"requiredAlias":null,"optionalAlias":null},'
|
|
90
|
+
b'"partial":{"required_nullable":null,"optional_nullable":null}}',
|
|
91
|
+
id="ordinary-nesting-list-union-and-partial",
|
|
92
|
+
),
|
|
93
|
+
pytest.param(
|
|
94
|
+
[AliasedRequest(requiredAlias=None), aliased],
|
|
95
|
+
b'[{"requiredAlias":null},{"requiredAlias":null,"optionalAlias":null}]',
|
|
96
|
+
id="list-root-with-models",
|
|
97
|
+
),
|
|
98
|
+
pytest.param(
|
|
99
|
+
{"ordinary": AliasedRequest(requiredAlias=None), "explicit": None},
|
|
100
|
+
b'{"ordinary":{"requiredAlias":null},"explicit":null}',
|
|
101
|
+
id="dictionary-root-with-model",
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize("payload, expected", request_cases())
|
|
107
|
+
@pytest.mark.parametrize("body_argument", ["json_model", "json_data"])
|
|
108
|
+
def test_sync_request_field_presence(payload, expected, body_argument):
|
|
109
|
+
captured = []
|
|
110
|
+
|
|
111
|
+
def respond(request):
|
|
112
|
+
captured.append(request.content)
|
|
113
|
+
return httpx.Response(200, json={})
|
|
114
|
+
|
|
115
|
+
with httpx.Client(transport=httpx.MockTransport(respond)) as transport:
|
|
116
|
+
client = ClientBase("https://example.test", client=transport)
|
|
117
|
+
client._make_request("/presence", **{body_argument: payload})
|
|
118
|
+
|
|
119
|
+
assert captured == [expected]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.parametrize("payload, expected", request_cases())
|
|
123
|
+
@pytest.mark.parametrize("body_argument", ["json_model", "json_data"])
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_async_request_field_presence(payload, expected, body_argument):
|
|
126
|
+
captured = []
|
|
127
|
+
|
|
128
|
+
def respond(request):
|
|
129
|
+
captured.append(request.content)
|
|
130
|
+
return httpx.Response(200, json={})
|
|
131
|
+
|
|
132
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as transport:
|
|
133
|
+
client = AsyncClientBase("https://example.test", client=transport)
|
|
134
|
+
await client._make_request("/presence", **{body_argument: payload})
|
|
135
|
+
|
|
136
|
+
assert captured == [expected]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Header absence does not weaken the same model's required JSON body fields."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
9
|
+
|
|
10
|
+
from reflectapi_runtime import ApplicationError, AsyncClientBase, ClientBase
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RequiredNullableHeaders(BaseModel):
|
|
14
|
+
# Exercise errors reported by Python field name as well as aliased input.
|
|
15
|
+
model_config = ConfigDict(loc_by_alias=False)
|
|
16
|
+
|
|
17
|
+
expires_at: datetime | None = Field(validation_alias="x-expires-at")
|
|
18
|
+
request_id: str | None = Field(alias="x-request-id")
|
|
19
|
+
retry_after: str | None = Field(validation_alias="retry-after")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def assert_headers(headers):
|
|
23
|
+
assert isinstance(headers, RequiredNullableHeaders)
|
|
24
|
+
assert headers.expires_at is None
|
|
25
|
+
assert headers.request_id == "req-1"
|
|
26
|
+
assert headers.retry_after is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("status", [200, 503])
|
|
30
|
+
@pytest.mark.parametrize("malformed", [False, True])
|
|
31
|
+
def test_sync_required_nullable_response_headers(status, malformed):
|
|
32
|
+
wire_headers = {"X-Request-ID": "req-1"}
|
|
33
|
+
if malformed:
|
|
34
|
+
wire_headers["X-Expires-At"] = "invalid-datetime"
|
|
35
|
+
|
|
36
|
+
def respond(request):
|
|
37
|
+
return httpx.Response(status, headers=wire_headers, json={})
|
|
38
|
+
|
|
39
|
+
# Body validation still requires nullable keys when headers share the model.
|
|
40
|
+
with pytest.raises(PydanticValidationError):
|
|
41
|
+
RequiredNullableHeaders.model_validate({"x-request-id": "req-1"})
|
|
42
|
+
|
|
43
|
+
with httpx.Client(transport=httpx.MockTransport(respond)) as transport:
|
|
44
|
+
client = ClientBase("https://example.test", client=transport)
|
|
45
|
+
if status == 503:
|
|
46
|
+
with pytest.raises(ApplicationError) as exc:
|
|
47
|
+
client._make_request(
|
|
48
|
+
"/headers", response_headers_model=RequiredNullableHeaders
|
|
49
|
+
)
|
|
50
|
+
result = exc.value
|
|
51
|
+
assert result.status_code == 503
|
|
52
|
+
else:
|
|
53
|
+
result = client._make_request(
|
|
54
|
+
"/headers", response_headers_model=RequiredNullableHeaders
|
|
55
|
+
)
|
|
56
|
+
assert_headers(result.headers)
|
|
57
|
+
if malformed:
|
|
58
|
+
assert result.metadata.headers["x-expires-at"] == "invalid-datetime"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize("status", [200, 503])
|
|
62
|
+
@pytest.mark.parametrize("malformed", [False, True])
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_async_required_nullable_response_headers(status, malformed):
|
|
65
|
+
wire_headers = {"X-Request-ID": "req-1"}
|
|
66
|
+
if malformed:
|
|
67
|
+
wire_headers["X-Expires-At"] = "invalid-datetime"
|
|
68
|
+
|
|
69
|
+
def respond(request):
|
|
70
|
+
return httpx.Response(status, headers=wire_headers, json={})
|
|
71
|
+
|
|
72
|
+
with pytest.raises(PydanticValidationError):
|
|
73
|
+
RequiredNullableHeaders.model_validate({"x-request-id": "req-1"})
|
|
74
|
+
|
|
75
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as transport:
|
|
76
|
+
client = AsyncClientBase("https://example.test", client=transport)
|
|
77
|
+
if status == 503:
|
|
78
|
+
with pytest.raises(ApplicationError) as exc:
|
|
79
|
+
await client._make_request(
|
|
80
|
+
"/headers", response_headers_model=RequiredNullableHeaders
|
|
81
|
+
)
|
|
82
|
+
result = exc.value
|
|
83
|
+
assert result.status_code == 503
|
|
84
|
+
else:
|
|
85
|
+
result = await client._make_request(
|
|
86
|
+
"/headers", response_headers_model=RequiredNullableHeaders
|
|
87
|
+
)
|
|
88
|
+
assert_headers(result.headers)
|
|
89
|
+
if malformed:
|
|
90
|
+
assert result.metadata.headers["x-expires-at"] == "invalid-datetime"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.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
|
{reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_enum_forward_compatibility.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|