reflectapi-runtime 0.17.2a4__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.
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/PKG-INFO +1 -1
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/pyproject.toml +1 -1
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/__init__.py +8 -17
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/client.py +38 -100
- reflectapi_runtime-0.17.3/src/reflectapi_runtime/duration.py +73 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/hypothesis_strategies.py +0 -26
- reflectapi_runtime-0.17.3/src/reflectapi_runtime/partial.py +110 -0
- reflectapi_runtime-0.17.3/src/reflectapi_runtime/serde.py +48 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/streaming.py +14 -18
- reflectapi_runtime-0.17.3/tests/test_codegen_regressions.py +151 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_edge_cases.py +3 -157
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_enhanced_features.py +26 -43
- reflectapi_runtime-0.17.3/tests/test_partial.py +231 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_sse.py +69 -0
- reflectapi_runtime-0.17.2a4/src/reflectapi_runtime/option.py +0 -296
- reflectapi_runtime-0.17.2a4/tests/test_option.py +0 -411
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/.gitignore +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/README.md +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/auth.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/batch.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/exceptions.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/middleware.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/response.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/sse.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/testing.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/transport.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/types.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/__init__.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_auth.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_auth_negative_cases.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_batch.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_client.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_exceptions.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_middleware.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_pydantic_serialization.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_response.py +0 -0
- {reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/tests/test_streaming.py +0 -0
- {reflectapi_runtime-0.17.2a4 → 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.
|
|
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
|
{reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/__init__.py
RENAMED
|
@@ -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,16 +37,9 @@ from .hypothesis_strategies import (
|
|
|
36
37
|
strategy_for_type,
|
|
37
38
|
)
|
|
38
39
|
from .middleware import AsyncMiddleware
|
|
39
|
-
from .
|
|
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
|
|
42
|
+
from .serde import parse_externally_tagged, serialize_externally_tagged
|
|
49
43
|
from .streaming import AsyncStreamingClient, StreamingResponse
|
|
50
44
|
from .testing import (
|
|
51
45
|
AsyncCassetteMiddleware,
|
|
@@ -56,7 +50,7 @@ from .testing import (
|
|
|
56
50
|
)
|
|
57
51
|
from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
|
|
58
52
|
|
|
59
|
-
__version__ = "0.17.
|
|
53
|
+
__version__ = "0.17.3"
|
|
60
54
|
|
|
61
55
|
__all__ = [
|
|
62
56
|
# Authentication
|
|
@@ -93,22 +87,19 @@ __all__ = [
|
|
|
93
87
|
"AsyncMiddleware",
|
|
94
88
|
"MockClient",
|
|
95
89
|
"NetworkError",
|
|
96
|
-
"
|
|
90
|
+
"ReflectapiDuration",
|
|
97
91
|
"ReflectapiEmpty",
|
|
98
92
|
"ReflectapiInfallible",
|
|
99
|
-
"
|
|
93
|
+
"ReflectapiPartialModel",
|
|
94
|
+
"parse_externally_tagged",
|
|
95
|
+
"serialize_externally_tagged",
|
|
100
96
|
"StreamingResponse",
|
|
101
97
|
"TestClientMixin",
|
|
102
98
|
"TimeoutError",
|
|
103
99
|
"TransportMetadata",
|
|
104
|
-
"Undefined",
|
|
105
100
|
"ValidationError",
|
|
106
101
|
"api_model_strategy",
|
|
107
102
|
"enhanced_strategy_for_type",
|
|
108
|
-
"none",
|
|
109
|
-
"serialize_option_dict",
|
|
110
|
-
"some",
|
|
111
103
|
"strategy_for_pydantic_model",
|
|
112
104
|
"strategy_for_type",
|
|
113
|
-
"undefined",
|
|
114
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
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
isinstance(field_value, ReflectapiOption)
|
|
280
|
-
for field_value in raw_data.values()
|
|
281
|
-
)
|
|
273
|
+
from .partial import ReflectapiPartialModel
|
|
282
274
|
|
|
283
|
-
if
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
848
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
871
|
-
isinstance(field_value, ReflectapiOption)
|
|
872
|
-
for field_value in raw_data.values()
|
|
873
|
-
)
|
|
835
|
+
from .partial import ReflectapiPartialModel
|
|
874
836
|
|
|
875
|
-
if
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Serialization helpers used by generated ReflectAPI clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
VariantHandler = Callable[[Any], Any] | str
|
|
10
|
+
VariantSerializers = dict[str, tuple[Callable[[Any], bool], Callable[[Any], Any]]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_externally_tagged(
|
|
14
|
+
data: Any,
|
|
15
|
+
variants: dict[str, VariantHandler],
|
|
16
|
+
types: tuple[type[Any], ...],
|
|
17
|
+
enum_name: str,
|
|
18
|
+
) -> Any:
|
|
19
|
+
"""Parse an externally tagged enum from ``{"Variant": value}`` format."""
|
|
20
|
+
if types and isinstance(data, types):
|
|
21
|
+
return data
|
|
22
|
+
if isinstance(data, str) and data in variants:
|
|
23
|
+
handler = variants[data]
|
|
24
|
+
if handler == "_unit":
|
|
25
|
+
return data
|
|
26
|
+
if isinstance(data, dict):
|
|
27
|
+
if len(data) != 1:
|
|
28
|
+
raise ValueError("Externally tagged enum must have exactly one key")
|
|
29
|
+
key, value = next(iter(data.items()))
|
|
30
|
+
if key in variants:
|
|
31
|
+
handler = variants[key]
|
|
32
|
+
if handler == "_unit":
|
|
33
|
+
return key
|
|
34
|
+
if callable(handler):
|
|
35
|
+
return handler(value)
|
|
36
|
+
raise ValueError(f"Unknown variant for {enum_name}: {data}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def serialize_externally_tagged(
|
|
40
|
+
root: Any,
|
|
41
|
+
serializers: VariantSerializers,
|
|
42
|
+
enum_name: str,
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Serialize an externally tagged enum to ``{"Variant": value}`` format."""
|
|
45
|
+
for _variant_name, (check, serialize) in serializers.items():
|
|
46
|
+
if check(root):
|
|
47
|
+
return serialize(root)
|
|
48
|
+
raise ValueError(f"Cannot serialize {enum_name} variant: {type(root)}")
|
{reflectapi_runtime-0.17.2a4 → reflectapi_runtime-0.17.3}/src/reflectapi_runtime/streaming.py
RENAMED
|
@@ -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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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,
|