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.
Files changed (40) hide show
  1. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/PKG-INFO +13 -5
  2. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/README.md +12 -4
  3. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/pyproject.toml +1 -1
  4. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/__init__.py +1 -1
  5. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/client.py +35 -37
  6. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/partial.py +7 -5
  7. reflectapi_runtime-0.21.0/src/reflectapi_runtime/py.typed +0 -0
  8. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_codegen_regressions.py +4 -5
  9. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_partial.py +22 -0
  10. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_pydantic_serialization.py +4 -2
  11. reflectapi_runtime-0.21.0/tests/test_request_field_presence.py +136 -0
  12. reflectapi_runtime-0.21.0/tests/test_response_header_presence.py +90 -0
  13. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/.gitignore +0 -0
  14. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/auth.py +0 -0
  15. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/batch.py +0 -0
  16. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/duration.py +0 -0
  17. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/exceptions.py +0 -0
  18. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
  19. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/middleware.py +0 -0
  20. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/response.py +0 -0
  21. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/serde.py +0 -0
  22. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/sse.py +0 -0
  23. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/streaming.py +0 -0
  24. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/testing.py +0 -0
  25. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/transport.py +0 -0
  26. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/src/reflectapi_runtime/types.py +0 -0
  27. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/__init__.py +0 -0
  28. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_auth.py +0 -0
  29. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_auth_negative_cases.py +0 -0
  30. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_batch.py +0 -0
  31. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_client.py +0 -0
  32. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_edge_cases.py +0 -0
  33. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_enhanced_features.py +0 -0
  34. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_enum_forward_compatibility.py +0 -0
  35. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_exceptions.py +0 -0
  36. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_middleware.py +0 -0
  37. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_response.py +0 -0
  38. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_sse.py +0 -0
  39. {reflectapi_runtime-0.20.0 → reflectapi_runtime-0.21.0}/tests/test_streaming.py +0 -0
  40. {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.20.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 three-state
64
- fields (value / explicit-null / absent) so absent and explicit-null can
65
- round-trip, tracked via Pydantic's `model_fields_set`.
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.20 require the 0.20 runtime.
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 three-state
23
- fields (value / explicit-null / absent) so absent and explicit-null can
24
- round-trip, tracked via Pydantic's `model_fields_set`.
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.20 require the 0.20 runtime.
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reflectapi-runtime"
7
- version = "0.20.0"
7
+ version = "0.21.0"
8
8
  description = "Runtime library for ReflectAPI Python clients"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -55,7 +55,7 @@ from .testing import (
55
55
  )
56
56
  from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
57
57
 
58
- __version__ = "0.20.0"
58
+ __version__ = "0.21.0"
59
59
 
60
60
  __all__ = [
61
61
  # Authentication
@@ -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(exclude_none=True)
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
- ``ReflectapiPartialModel`` subclasses carry their own
426
- ``@model_serializer`` that omits keys not in
427
- ``model_fields_set``, so explicit ``None`` values must reach
428
- the wire (they encode the protocol's "explicit null" state).
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
- from .partial import ReflectapiPartialModel
443
-
444
- if isinstance(json_model, ReflectapiPartialModel):
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
- ``ReflectapiPartialModel`` subclasses carry their own
951
- ``@model_serializer`` that omits keys not in
952
- ``model_fields_set``, so explicit ``None`` values must reach
953
- the wire (they encode the protocol's "explicit null" state).
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
- from .partial import ReflectapiPartialModel
968
-
969
- if isinstance(json_model, ReflectapiPartialModel):
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 makes every generated class with at least one
19
- ``reflectapi::Option<T>`` field inherit from this class instead of
20
- ``pydantic.BaseModel``.
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 partial (three-state) fields.
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
- @model_serializer(mode="wrap")
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]:
@@ -83,11 +83,10 @@ class TestDurationRoundTrip:
83
83
 
84
84
 
85
85
  class TestRequestSerializationOmitsUnsetOptional:
86
- """Plain ``BaseModel`` request types must omit unset optional fields
87
- from the wire, matching what ``#[serde(skip_serializing_if =
88
- "Option::is_none")]`` produces on the server side. Partial models,
89
- by contrast, must keep explicit ``None`` to preserve the
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
+ )
@@ -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","active":true}'
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
- assert parsed_data["address"]["country"] == "USA" # Default value
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"