reflectapi-runtime 0.17.2a5__tar.gz → 0.17.3__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 (38) hide show
  1. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/PKG-INFO +1 -1
  2. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/pyproject.toml +1 -1
  3. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/__init__.py +5 -17
  4. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/client.py +38 -100
  5. reflectapi_runtime-0.17.3/src/reflectapi_runtime/duration.py +73 -0
  6. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/hypothesis_strategies.py +0 -26
  7. reflectapi_runtime-0.17.3/src/reflectapi_runtime/partial.py +110 -0
  8. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/streaming.py +14 -18
  9. reflectapi_runtime-0.17.3/tests/test_codegen_regressions.py +151 -0
  10. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_edge_cases.py +3 -157
  11. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_enhanced_features.py +26 -43
  12. reflectapi_runtime-0.17.3/tests/test_partial.py +231 -0
  13. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_sse.py +69 -0
  14. reflectapi_runtime-0.17.2a5/src/reflectapi_runtime/option.py +0 -296
  15. reflectapi_runtime-0.17.2a5/tests/test_option.py +0 -411
  16. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/.gitignore +0 -0
  17. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/README.md +0 -0
  18. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/auth.py +0 -0
  19. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/batch.py +0 -0
  20. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/exceptions.py +0 -0
  21. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/middleware.py +0 -0
  22. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/response.py +0 -0
  23. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/serde.py +0 -0
  24. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/sse.py +0 -0
  25. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/testing.py +0 -0
  26. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/transport.py +0 -0
  27. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/types.py +0 -0
  28. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/__init__.py +0 -0
  29. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_auth.py +0 -0
  30. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_auth_negative_cases.py +0 -0
  31. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_batch.py +0 -0
  32. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_client.py +0 -0
  33. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_exceptions.py +0 -0
  34. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_middleware.py +0 -0
  35. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_pydantic_serialization.py +0 -0
  36. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_response.py +0 -0
  37. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_streaming.py +0 -0
  38. {reflectapi_runtime-0.17.2a5 → reflectapi_runtime-0.17.3}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflectapi-runtime
3
- Version: 0.17.2a5
3
+ Version: 0.17.3
4
4
  Summary: Runtime library for ReflectAPI Python clients
5
5
  Project-URL: Homepage, https://github.com/thepartly/reflectapi
6
6
  Project-URL: Repository, https://github.com/thepartly/reflectapi
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reflectapi-runtime"
7
- version = "0.17.2a5"
7
+ version = "0.17.3"
8
8
  description = "Runtime library for ReflectAPI Python clients"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -20,6 +20,7 @@ from .auth import (
20
20
  )
21
21
  from .batch import BatchClient
22
22
  from .client import AsyncClientBase, ClientBase
23
+ from .duration import ReflectapiDuration
23
24
  from .transport import AsyncClient, Client, Request, Response
24
25
  from .exceptions import (
25
26
  ApiError,
@@ -36,15 +37,7 @@ from .hypothesis_strategies import (
36
37
  strategy_for_type,
37
38
  )
38
39
  from .middleware import AsyncMiddleware
39
- from .option import (
40
- Option,
41
- ReflectapiOption,
42
- Undefined,
43
- none,
44
- serialize_option_dict,
45
- some,
46
- undefined,
47
- )
40
+ from .partial import ReflectapiPartialModel
48
41
  from .response import ApiResponse, TransportMetadata
49
42
  from .serde import parse_externally_tagged, serialize_externally_tagged
50
43
  from .streaming import AsyncStreamingClient, StreamingResponse
@@ -57,7 +50,7 @@ from .testing import (
57
50
  )
58
51
  from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
59
52
 
60
- __version__ = "0.17.2a5"
53
+ __version__ = "0.17.3"
61
54
 
62
55
  __all__ = [
63
56
  # Authentication
@@ -94,24 +87,19 @@ __all__ = [
94
87
  "AsyncMiddleware",
95
88
  "MockClient",
96
89
  "NetworkError",
97
- "Option",
90
+ "ReflectapiDuration",
98
91
  "ReflectapiEmpty",
99
92
  "ReflectapiInfallible",
100
- "ReflectapiOption",
93
+ "ReflectapiPartialModel",
101
94
  "parse_externally_tagged",
102
95
  "serialize_externally_tagged",
103
96
  "StreamingResponse",
104
97
  "TestClientMixin",
105
98
  "TimeoutError",
106
99
  "TransportMetadata",
107
- "Undefined",
108
100
  "ValidationError",
109
101
  "api_model_strategy",
110
102
  "enhanced_strategy_for_type",
111
- "none",
112
- "serialize_option_dict",
113
- "some",
114
103
  "strategy_for_pydantic_model",
115
104
  "strategy_for_type",
116
- "undefined",
117
105
  ]
@@ -21,7 +21,6 @@ from .middleware import (
21
21
  SyncMiddleware,
22
22
  SyncMiddlewareChain,
23
23
  )
24
- from .option import serialize_option_dict
25
24
  from .response import ApiResponse, TransportMetadata
26
25
  from .sse import aparse_sse, parse_sse
27
26
  from .transport import AsyncClient, Client, Request, Response
@@ -252,60 +251,34 @@ class ClientBase(ABC):
252
251
  def _serialize_request_body(
253
252
  self, json_model: BaseModel | int | float | str | bool | list | dict
254
253
  ) -> tuple[bytes, dict[str, str]]:
255
- """Serialize request body from Pydantic model or primitive type."""
256
- from .option import ReflectapiOption
254
+ """Serialize the request body.
257
255
 
256
+ ``ReflectapiPartialModel`` subclasses carry their own
257
+ ``@model_serializer`` that omits keys not in
258
+ ``model_fields_set``, so explicit ``None`` values must reach
259
+ the wire (they encode the protocol's "explicit null" state).
260
+
261
+ Plain Pydantic models use ``exclude_none=True`` so an unset
262
+ optional field renders as an absent key, matching the wire
263
+ shape produced by serde with
264
+ ``#[serde(skip_serializing_if = "Option::is_none")]``.
265
+ """
258
266
  # Handle primitive types (for untagged unions)
259
- if not hasattr(json_model, "model_dump"):
267
+ if not hasattr(json_model, "model_dump_json"):
260
268
  content = json.dumps(
261
269
  json_model, default=_json_serializer, separators=(",", ":")
262
270
  ).encode("utf-8")
263
- headers = {"Content-Type": "application/json"}
264
- return content, headers
265
-
266
- # Check if model has any ReflectapiOption fields that need special handling
267
- raw_data = json_model.model_dump(exclude_none=False)
268
-
269
- # Handle case where RootModel serializes to primitive value (e.g., strings for unit variants)
270
- if not isinstance(raw_data, dict):
271
- # For primitive values, use Pydantic's built-in JSON serialization
272
- content = json_model.model_dump_json(
273
- exclude_none=True, by_alias=True
274
- ).encode("utf-8")
275
- headers = {"Content-Type": "application/json"}
276
- return content, headers
271
+ return content, {"Content-Type": "application/json"}
277
272
 
278
- has_reflectapi_options = any(
279
- isinstance(field_value, ReflectapiOption)
280
- for field_value in raw_data.values()
281
- )
273
+ from .partial import ReflectapiPartialModel
282
274
 
283
- if has_reflectapi_options:
284
- # Process each field to handle ReflectapiOption properly
285
- processed_fields = {}
286
- for field_name, field_value in raw_data.items():
287
- if isinstance(field_value, ReflectapiOption):
288
- if not field_value.is_undefined:
289
- # Include the unwrapped value (including None for explicit null)
290
- processed_fields[field_name] = field_value._value
291
- # Skip undefined fields entirely - don't include them at all
292
- else:
293
- # Include all other fields that aren't None (unless they're meaningful None values)
294
- if field_value is not None:
295
- processed_fields[field_name] = field_value
296
-
297
- # Use json serialization with datetime handler for proper serialization
298
- content = json.dumps(
299
- processed_fields, default=_json_serializer, separators=(",", ":")
300
- ).encode("utf-8")
275
+ if isinstance(json_model, ReflectapiPartialModel):
276
+ content = json_model.model_dump_json(by_alias=True).encode("utf-8")
301
277
  else:
302
- # Use Pydantic's built-in JSON serialization with exclude_none and by_alias for proper handling
303
278
  content = json_model.model_dump_json(
304
- exclude_none=True, by_alias=True
279
+ by_alias=True, exclude_none=True
305
280
  ).encode("utf-8")
306
-
307
- headers = {"Content-Type": "application/json"}
308
- return content, headers
281
+ return content, {"Content-Type": "application/json"}
309
282
 
310
283
  def _build_headers(
311
284
  self, base_headers: dict[str, str], headers_model: BaseModel | None
@@ -334,12 +307,8 @@ class ClientBase(ABC):
334
307
  content, base_headers = self._serialize_request_body(json_model)
335
308
  headers = self._build_headers(base_headers, headers_model)
336
309
  elif json_data is not None:
337
- if isinstance(json_data, dict):
338
- processed_json_data = serialize_option_dict(json_data)
339
- else:
340
- processed_json_data = json_data
341
310
  content = json.dumps(
342
- processed_json_data,
311
+ json_data,
343
312
  default=_json_serializer,
344
313
  separators=(",", ":"),
345
314
  ).encode("utf-8")
@@ -844,61 +813,34 @@ class AsyncClientBase(ABC):
844
813
  def _serialize_request_body(
845
814
  self, json_model: BaseModel | int | float | str | bool | list | dict
846
815
  ) -> tuple[bytes, dict[str, str]]:
847
- """Serialize request body from Pydantic model or primitive type."""
848
- from .option import ReflectapiOption
816
+ """Serialize the request body.
817
+
818
+ ``ReflectapiPartialModel`` subclasses carry their own
819
+ ``@model_serializer`` that omits keys not in
820
+ ``model_fields_set``, so explicit ``None`` values must reach
821
+ the wire (they encode the protocol's "explicit null" state).
849
822
 
823
+ Plain Pydantic models use ``exclude_none=True`` so an unset
824
+ optional field renders as an absent key, matching the wire
825
+ shape produced by serde with
826
+ ``#[serde(skip_serializing_if = "Option::is_none")]``.
827
+ """
850
828
  # Handle primitive types (for untagged unions)
851
- if not hasattr(json_model, "model_dump"):
829
+ if not hasattr(json_model, "model_dump_json"):
852
830
  content = json.dumps(
853
831
  json_model, default=_json_serializer, separators=(",", ":")
854
832
  ).encode("utf-8")
855
- headers = {"Content-Type": "application/json"}
856
- return content, headers
857
-
858
- # Check if model has any ReflectapiOption fields that need special handling
859
- raw_data = json_model.model_dump(exclude_none=False)
860
-
861
- # Handle case where RootModel serializes to primitive value (e.g., strings for unit variants)
862
- if not isinstance(raw_data, dict):
863
- # For primitive values, use Pydantic's built-in JSON serialization
864
- content = json_model.model_dump_json(
865
- exclude_none=True, by_alias=True
866
- ).encode("utf-8")
867
- headers = {"Content-Type": "application/json"}
868
- return content, headers
833
+ return content, {"Content-Type": "application/json"}
869
834
 
870
- has_reflectapi_options = any(
871
- isinstance(field_value, ReflectapiOption)
872
- for field_value in raw_data.values()
873
- )
835
+ from .partial import ReflectapiPartialModel
874
836
 
875
- if has_reflectapi_options:
876
- # Process each field to handle ReflectapiOption properly
877
- processed_fields = {}
878
- for field_name, field_value in raw_data.items():
879
- if isinstance(field_value, ReflectapiOption):
880
- if not field_value.is_undefined:
881
- # Include the unwrapped value (including None for explicit null)
882
- processed_fields[field_name] = field_value._value
883
- # Skip undefined fields entirely - don't include them at all
884
- else:
885
- # Include all other fields that aren't None (unless they're meaningful None values)
886
- if field_value is not None:
887
- processed_fields[field_name] = field_value
888
-
889
- # Use json serialization with datetime handler for proper serialization
890
- content = json.dumps(
891
- processed_fields, default=_json_serializer, separators=(",", ":")
892
- ).encode("utf-8")
837
+ if isinstance(json_model, ReflectapiPartialModel):
838
+ content = json_model.model_dump_json(by_alias=True).encode("utf-8")
893
839
  else:
894
- # Use Pydantic's built-in JSON serialization with exclude_none and by_alias for proper handling
895
840
  content = json_model.model_dump_json(
896
- exclude_none=True, by_alias=True
841
+ by_alias=True, exclude_none=True
897
842
  ).encode("utf-8")
898
-
899
- headers = {"Content-Type": "application/json"}
900
-
901
- return content, headers
843
+ return content, {"Content-Type": "application/json"}
902
844
 
903
845
  def _build_headers(
904
846
  self, base_headers: dict[str, str], headers_model: BaseModel | None
@@ -927,12 +869,8 @@ class AsyncClientBase(ABC):
927
869
  content, base_headers = self._serialize_request_body(json_model)
928
870
  headers = self._build_headers(base_headers, headers_model)
929
871
  elif json_data is not None:
930
- if isinstance(json_data, dict):
931
- processed_json_data = serialize_option_dict(json_data)
932
- else:
933
- processed_json_data = json_data
934
872
  content = json.dumps(
935
- processed_json_data,
873
+ json_data,
936
874
  default=_json_serializer,
937
875
  separators=(",", ":"),
938
876
  ).encode("utf-8")
@@ -0,0 +1,73 @@
1
+ """Wire-format adapter for Rust's ``std::time::Duration``.
2
+
3
+ serde serialises ``Duration`` as ``{"secs": <u64>, "nanos": <u32>}``.
4
+ Pydantic v2's built-in ``timedelta`` validator accepts ISO-8601 strings,
5
+ ints, and floats — it does **not** accept the ``{secs, nanos}`` dict
6
+ that the server actually sends. The bare ``timedelta`` annotation
7
+ emitted by older codegen therefore failed validation on every response
8
+ that carried a ``Duration`` field.
9
+
10
+ ``ReflectapiDuration`` is the type the generated client uses instead. It
11
+ preserves the ergonomic Python ``timedelta`` API (``td.total_seconds()``,
12
+ arithmetic, comparisons) and round-trips the serde shape:
13
+
14
+ - **Validating** (server → client): a ``{"secs": …, "nanos": …}`` dict
15
+ is converted to a ``timedelta``; ints/floats and existing
16
+ ``timedelta`` instances pass through unchanged so users can construct
17
+ models from Python values directly.
18
+ - **Serialising** (client → server): the ``timedelta`` is written back
19
+ as ``{"secs": <int>, "nanos": <int>}`` so the wire payload round-trips
20
+ cleanly through ``serde::Deserialize`` on the server.
21
+
22
+ Precision caveat: ``timedelta`` stores microseconds, so the bottom three
23
+ decimal digits of ``nanos`` are truncated on round-trip. If a server
24
+ ever sends a ``nanos`` value not divisible by 1 000 (sub-microsecond),
25
+ the recovered ``nanos`` will be the closest microsecond-aligned value.
26
+ For the durations reflectapi APIs typically carry (timeouts, retry
27
+ hints, rate-limit windows) this is well below the noise floor.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from datetime import timedelta
33
+ from typing import Annotated, Any
34
+
35
+ from pydantic import BeforeValidator, PlainSerializer
36
+
37
+ _NS_PER_SECOND = 1_000_000_000
38
+
39
+
40
+ def _validate(value: Any) -> Any:
41
+ """Accept serde's ``{secs, nanos}`` dict; pass other forms through."""
42
+ if isinstance(value, dict):
43
+ secs = value.get("secs", 0)
44
+ nanos = value.get("nanos", 0)
45
+ if not isinstance(secs, (int, float)) or not isinstance(nanos, (int, float)):
46
+ return value # let Pydantic raise its own validation error
47
+ return timedelta(seconds=secs, microseconds=nanos / 1_000)
48
+ return value
49
+
50
+
51
+ def _serialise(value: timedelta) -> dict[str, int]:
52
+ """Emit the wire shape Rust's ``serde::Deserialize<Duration>`` expects."""
53
+ if not isinstance(value, timedelta):
54
+ # Pydantic already coerced; if anything else slips through, surface
55
+ # the failure rather than papering over it.
56
+ raise TypeError(
57
+ f"ReflectapiDuration serialiser expected a timedelta, got {type(value).__name__}"
58
+ )
59
+ # Pull total nanoseconds out of the timedelta in two integer pieces
60
+ # so we never round-trip through float and lose precision below the
61
+ # microsecond level.
62
+ total_us = (value.days * 86_400 + value.seconds) * 1_000_000 + value.microseconds
63
+ secs, micros = divmod(total_us, 1_000_000)
64
+ nanos = micros * 1_000
65
+ return {"secs": secs, "nanos": nanos}
66
+
67
+
68
+ ReflectapiDuration = Annotated[
69
+ timedelta,
70
+ BeforeValidator(_validate),
71
+ PlainSerializer(_serialise, when_used="json"),
72
+ ]
73
+ """Pydantic field type for ``std::time::Duration`` — see module docstring."""
@@ -20,25 +20,8 @@ except ImportError:
20
20
 
21
21
  from pydantic import BaseModel
22
22
 
23
- from .option import ReflectapiOption, Undefined
24
-
25
23
  if HAS_HYPOTHESIS:
26
24
 
27
- def strategy_for_option(inner_strategy: st.SearchStrategy) -> st.SearchStrategy:
28
- """Create a strategy for ReflectapiOption types.
29
-
30
- Args:
31
- inner_strategy: Strategy for the inner type T in Option<T>.
32
-
33
- Returns:
34
- Strategy that produces ReflectapiOption instances with undefined, None, or values.
35
- """
36
- return st.one_of(
37
- st.just(ReflectapiOption(Undefined)), # Undefined case
38
- st.just(ReflectapiOption(None)), # None case
39
- inner_strategy.map(ReflectapiOption), # Some(value) case
40
- )
41
-
42
25
  def strategy_for_type(type_hint: type) -> st.SearchStrategy:
43
26
  """Generate a Hypothesis strategy for a given type hint.
44
27
 
@@ -69,15 +52,6 @@ if HAS_HYPOTHESIS:
69
52
  elif type_hint is uuid.UUID:
70
53
  return st.uuids()
71
54
 
72
- # Handle ReflectapiOption specifically
73
- if (
74
- hasattr(type_hint, "__origin__")
75
- and type_hint.__origin__ is ReflectapiOption
76
- ):
77
- inner_type = get_args(type_hint)[0] if get_args(type_hint) else Any
78
- inner_strategy = strategy_for_type(inner_type)
79
- return strategy_for_option(inner_strategy)
80
-
81
55
  # Handle generic types
82
56
  origin = get_origin(type_hint)
83
57
  args = get_args(type_hint)
@@ -0,0 +1,110 @@
1
+ """Base class for generated Pydantic models with partial fields.
2
+
3
+ reflectapi schemas distinguish three states for nullable fields
4
+ declared as ``reflectapi::Option<T>``: ``Some(value)``, explicit
5
+ ``null``, and "key absent on the wire" (``Undefined``). TypeScript
6
+ clients express this natively (``field?: T | null``); Rust clients use
7
+ the ``reflectapi::Option<T>`` enum. The Python equivalent uses
8
+ ``ReflectapiPartialModel`` — a ``BaseModel`` mixin that:
9
+
10
+ - leaves field types as plain ``T | None``, so Pydantic validates them
11
+ exactly as it would any other nullable field;
12
+ - relies on ``model_fields_set`` — Pydantic's built-in record of which
13
+ fields were actually provided during deserialise or construction —
14
+ as the source of truth for "was this on the wire?";
15
+ - emits a wire payload that omits keys not in ``model_fields_set``,
16
+ preserving the absent-vs-null distinction round-trip.
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``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from pydantic import BaseModel, model_serializer
28
+
29
+
30
+ class ReflectapiPartialModel(BaseModel):
31
+ """Base class for reflectapi models with partial (three-state) fields.
32
+
33
+ Wire-format guarantee: a field that was never explicitly set on the
34
+ instance is omitted from the serialised output entirely. A field set
35
+ to ``None`` is emitted as ``null``. A field set to a value is emitted
36
+ as that value. This mirrors what TypeScript clients produce for
37
+ ``field?: T | null`` and what Rust clients produce for
38
+ ``reflectapi::Option<T>``.
39
+
40
+ Usage on the read path:
41
+
42
+ .. code-block:: python
43
+
44
+ item = Item.model_validate({"name": "x"}) # snapshot key absent
45
+ "snapshot" in item.model_fields_set # False — was absent
46
+
47
+ item = Item.model_validate({"name": "x", "snapshot": None})
48
+ "snapshot" in item.model_fields_set # True — explicit null
49
+ item.snapshot is None # True
50
+
51
+ Usage on the write path:
52
+
53
+ .. code-block:: python
54
+
55
+ Item(name="x").model_dump_json()
56
+ # '{"name":"x"}' — snapshot omitted because it wasn't set
57
+
58
+ Item(name="x", snapshot=None).model_dump_json()
59
+ # '{"name":"x","snapshot":null}'
60
+ """
61
+
62
+ # NOTE on ``model_fields_set`` and post-construction assignment:
63
+ # Pydantic populates ``model_fields_set`` during construction
64
+ # (kwargs) and deserialise (``model_validate``). Subsequent
65
+ # attribute writes do **not** add to that set unless the model's
66
+ # ``model_config`` enables ``validate_assignment=True``. The
67
+ # generated client code emits ``ConfigDict(extra="ignore",
68
+ # populate_by_name=True, validate_assignment=True)`` on every
69
+ # partial class for this reason, so users can also do
70
+ # ``m.snapshot = None`` after construction and have it land on the
71
+ # wire.
72
+
73
+ @model_serializer(mode="wrap")
74
+ def _serialize_partial(
75
+ self, handler: Any, info: Any | None = None
76
+ ) -> dict[str, Any]:
77
+ """Drop fields the caller never explicitly set.
78
+
79
+ Pydantic populates ``model_fields_set`` with the *field names*
80
+ of every field that was either present in the input dict during
81
+ ``model_validate`` *or* passed as a keyword to ``__init__``.
82
+ Defaults populated by Pydantic itself are excluded — which is
83
+ exactly the "was this on the wire?" signal we need.
84
+
85
+ Two complications the implementation has to handle:
86
+
87
+ 1. When ``by_alias=True`` is in effect, the handler returns a
88
+ dict keyed by the field's serialization alias, not by its
89
+ Python attribute name. ``model_fields_set`` always holds the
90
+ Python name, so we expand it into the set of names that
91
+ could plausibly appear in ``data`` (Python name + alias).
92
+
93
+ 2. RootModel and friends may return non-dict data (e.g. a bare
94
+ string for a unit-variant enum). In that case there's nothing
95
+ to filter; just pass it through.
96
+ """
97
+ data = handler(self)
98
+ if not isinstance(data, dict):
99
+ return data
100
+ emit_keys: set[str] = set()
101
+ model_fields = type(self).model_fields
102
+ for name in self.model_fields_set:
103
+ emit_keys.add(name)
104
+ field_info = model_fields.get(name)
105
+ if field_info is None:
106
+ continue
107
+ alias = field_info.serialization_alias or field_info.alias
108
+ if alias:
109
+ emit_keys.add(alias)
110
+ return {key: value for key, value in data.items() if key in emit_keys}
@@ -11,7 +11,6 @@ import httpx
11
11
  from .auth import AuthHandler
12
12
  from .exceptions import ApplicationError, NetworkError, TimeoutError
13
13
  from .middleware import AsyncMiddleware, AsyncMiddlewareChain
14
- from .option import serialize_option_dict
15
14
  from .response import TransportMetadata
16
15
 
17
16
  if TYPE_CHECKING:
@@ -237,26 +236,23 @@ class AsyncStreamingClient:
237
236
 
238
237
  request_headers = headers.copy() if headers else {}
239
238
 
240
- # Serialize Pydantic model
239
+ # Serialize Pydantic model. `ReflectapiPartialModel` emits its
240
+ # own @model_serializer that omits keys not in
241
+ # `model_fields_set`; explicit `None` values must round-trip
242
+ # for it. Plain Pydantic models use `exclude_none=True` so
243
+ # unset optional fields stay absent on the wire (matches
244
+ # serde's `skip_serializing_if = "Option::is_none"`).
241
245
  if json_model is not None:
242
- # Use Pydantic with improved ReflectapiOption serialization
243
- model_dict = json_model.model_dump(exclude_none=False) # Keep explicit None
244
-
245
- # Filter out Undefined values (ReflectapiOption serializer returns Undefined sentinel)
246
- from .option import Undefined
247
-
248
- final_dict = {
249
- k: v
250
- for k, v in model_dict.items()
251
- if not (hasattr(v, "__class__") and v is Undefined)
252
- }
253
-
254
- import json
255
-
256
- content = json.dumps(final_dict, separators=(",", ":")).encode("utf-8")
246
+ from .partial import ReflectapiPartialModel
247
+
248
+ if isinstance(json_model, ReflectapiPartialModel):
249
+ content = json_model.model_dump_json(by_alias=True).encode("utf-8")
250
+ else:
251
+ content = json_model.model_dump_json(
252
+ by_alias=True, exclude_none=True
253
+ ).encode("utf-8")
257
254
  request_headers["Content-Type"] = "application/json"
258
255
 
259
- # Build request with raw content
260
256
  request = self._client.build_request(
261
257
  method=method,
262
258
  url=url,