reflectapi-runtime 0.22.0__tar.gz → 0.23.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 (42) hide show
  1. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/PKG-INFO +3 -1
  2. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/pyproject.toml +3 -1
  3. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/__init__.py +7 -1
  4. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/partial.py +7 -6
  5. reflectapi_runtime-0.23.0/src/reflectapi_runtime/serde.py +393 -0
  6. reflectapi_runtime-0.23.0/tests/test_json_schema.py +335 -0
  7. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_partial.py +18 -0
  8. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_sse.py +7 -0
  9. reflectapi_runtime-0.22.0/src/reflectapi_runtime/serde.py +0 -141
  10. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/.gitignore +0 -0
  11. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/README.md +0 -0
  12. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/auth.py +0 -0
  13. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/batch.py +0 -0
  14. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/client.py +0 -0
  15. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/duration.py +0 -0
  16. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/exceptions.py +0 -0
  17. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
  18. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/middleware.py +0 -0
  19. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/py.typed +0 -0
  20. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/response.py +0 -0
  21. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/sse.py +0 -0
  22. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/streaming.py +0 -0
  23. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/testing.py +0 -0
  24. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/transport.py +0 -0
  25. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/types.py +0 -0
  26. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/__init__.py +0 -0
  27. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_auth.py +0 -0
  28. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_auth_negative_cases.py +0 -0
  29. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_batch.py +0 -0
  30. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_client.py +0 -0
  31. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_codegen_regressions.py +0 -0
  32. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_edge_cases.py +0 -0
  33. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_enhanced_features.py +0 -0
  34. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_enum_forward_compatibility.py +0 -0
  35. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_exceptions.py +0 -0
  36. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_middleware.py +0 -0
  37. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_pydantic_serialization.py +0 -0
  38. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_request_field_presence.py +0 -0
  39. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_response.py +0 -0
  40. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_response_header_presence.py +0 -0
  41. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_streaming.py +0 -0
  42. {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.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.22.0
3
+ Version: 0.23.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
@@ -28,6 +28,7 @@ Requires-Dist: pydantic>=2.12.0; python_version >= '3.14'
28
28
  Requires-Dist: typing-extensions>=4.5.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: hypothesis>=6.0.0; extra == 'dev'
31
+ Requires-Dist: jsonschema>=4.23.0; extra == 'dev'
31
32
  Requires-Dist: mypy>=1.5.0; extra == 'dev'
32
33
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
33
34
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
@@ -35,6 +36,7 @@ Requires-Dist: pytest>=7.0.0; extra == 'dev'
35
36
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
36
37
  Provides-Extra: test
37
38
  Requires-Dist: hypothesis>=6.0.0; extra == 'test'
39
+ Requires-Dist: jsonschema>=4.23.0; extra == 'test'
38
40
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'test'
39
41
  Requires-Dist: pytest>=7.0.0; extra == 'test'
40
42
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reflectapi-runtime"
7
- version = "0.22.0"
7
+ version = "0.23.0"
8
8
  description = "Runtime library for ReflectAPI Python clients"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -41,6 +41,7 @@ dev = [
41
41
  "pytest-asyncio>=0.21.0",
42
42
  "pytest-cov>=4.0.0",
43
43
  "hypothesis>=6.0.0",
44
+ "jsonschema>=4.23.0",
44
45
  "mypy>=1.5.0",
45
46
  "ruff>=0.1.0",
46
47
  ]
@@ -48,6 +49,7 @@ test = [
48
49
  "pytest>=7.0.0",
49
50
  "pytest-asyncio>=0.21.0",
50
51
  "hypothesis>=6.0.0",
52
+ "jsonschema>=4.23.0",
51
53
  ]
52
54
 
53
55
  [project.urls]
@@ -42,6 +42,9 @@ from .response import ApiResponse, ApiStream, AsyncApiStream, TransportMetadata
42
42
  from .serde import (
43
43
  UnknownEnumValue,
44
44
  classify_tagged_enum,
45
+ externally_tagged_json_schema,
46
+ flattened_enum_json_schema,
47
+ known_variants_json_schema,
45
48
  parse_externally_tagged,
46
49
  serialize_externally_tagged,
47
50
  )
@@ -55,7 +58,7 @@ from .testing import (
55
58
  )
56
59
  from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
57
60
 
58
- __version__ = "0.22.0"
61
+ __version__ = "0.23.0"
59
62
 
60
63
  __all__ = [
61
64
  # Authentication
@@ -96,6 +99,9 @@ __all__ = [
96
99
  "ReflectapiEmpty",
97
100
  "ReflectapiInfallible",
98
101
  "ReflectapiPartialModel",
102
+ "externally_tagged_json_schema",
103
+ "flattened_enum_json_schema",
104
+ "known_variants_json_schema",
99
105
  "parse_externally_tagged",
100
106
  "serialize_externally_tagged",
101
107
  "UnknownEnumValue",
@@ -70,12 +70,13 @@ class ReflectapiPartialModel(BaseModel):
70
70
  # ``m.snapshot = None`` after construction and have it land on the
71
71
  # wire.
72
72
 
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])
76
- def _serialize_partial(
77
- self, handler: Any, info: Any | None = None
78
- ) -> dict[str, Any]:
73
+ # Deliberately no return annotation and no ``return_type``: a declared
74
+ # ``dict[str, Any]`` replaces the model's serialization-mode JSON schema
75
+ # with a bare object, so consumers' OpenAPI specs lose the type. The
76
+ # missing annotation also keeps Pydantic 2.10 from resolving a postponed
77
+ # annotation in a subclass's module, which need not import ``Any``.
78
+ @model_serializer(mode="wrap")
79
+ def _serialize_partial(self, handler: Any, info: Any | None = None):
79
80
  """Drop fields the caller never explicitly set.
80
81
 
81
82
  Pydantic populates ``model_fields_set`` with the *field names*
@@ -0,0 +1,393 @@
1
+ """Serialization helpers used by generated ReflectAPI clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable, Sequence
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from pydantic import BaseModel, model_serializer, model_validator
10
+
11
+ if TYPE_CHECKING:
12
+ from pydantic import GetJsonSchemaHandler
13
+ from pydantic.json_schema import JsonSchemaValue
14
+ from pydantic_core import CoreSchema
15
+
16
+ VariantHandler = Callable[[Any], Any] | str
17
+ VariantSerializers = dict[str, tuple[Callable[[Any], bool], Callable[[Any], Any]]]
18
+
19
+ # Marks the JSON schema of an open enum's unknown case, which the enum drops.
20
+ _UNKNOWN_VARIANT = "x-reflectapi-unknown-variant"
21
+
22
+
23
+ class UnknownEnumValue(BaseModel):
24
+ """An output enum case whose wire tag was not in the generated schema.
25
+
26
+ Generated clients construct this after checking the wire discriminator. A
27
+ raw object cannot be validated directly as this case, which keeps malformed
28
+ known cases from silently falling through a Pydantic union.
29
+
30
+ It is left out of JSON schemas: an open enum is described by its known
31
+ variants, as the server's own schema describes it. Its own schema is a
32
+ marker that the enum's schema (``known_variants_json_schema``) drops. The
33
+ marker matches no value, so an enum generated without that hook still
34
+ describes its known variants only.
35
+ """
36
+
37
+ tag: str
38
+ raw: Any
39
+
40
+ @classmethod
41
+ def __get_pydantic_json_schema__(
42
+ cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
43
+ ) -> JsonSchemaValue:
44
+ return {"not": {}, _UNKNOWN_VARIANT: True}
45
+
46
+ @model_validator(mode="before")
47
+ @classmethod
48
+ def _instance_only(cls, value: Any) -> Any:
49
+ if not isinstance(value, cls):
50
+ raise ValueError("Unknown enum cases are created from unfamiliar wire tags")
51
+ return value
52
+
53
+ @model_serializer(mode="plain")
54
+ def _serialize_raw(self) -> Any:
55
+ return self.raw
56
+
57
+
58
+ def classify_tagged_enum(
59
+ data: Any,
60
+ known_tags: frozenset[str],
61
+ *,
62
+ representation: str,
63
+ tag_field: str = "",
64
+ content_field: str = "",
65
+ fallback_tag: str | None = None,
66
+ unknown_type: type[UnknownEnumValue] | None = None,
67
+ unit_tags: frozenset[str] = frozenset(),
68
+ ) -> Any:
69
+ """Classify an unfamiliar valid tag without masking other validation errors."""
70
+ if isinstance(data, UnknownEnumValue):
71
+ return data
72
+ if representation == "external":
73
+ if isinstance(data, str):
74
+ tag = data
75
+ elif isinstance(data, dict) and len(data) == 1:
76
+ tag = next(iter(data))
77
+ else:
78
+ return data
79
+ elif isinstance(data, dict):
80
+ if tag_field not in data or not isinstance(data[tag_field], str):
81
+ raise ValueError("Tagged enum requires a string discriminator")
82
+ tag = data[tag_field]
83
+ else:
84
+ return data
85
+ if isinstance(tag, str) and tag in unit_tags and isinstance(data, dict):
86
+ if representation == "adjacent" and data.get(content_field) is not None:
87
+ raise ValueError("Unit variant cannot carry non-null content")
88
+ if representation == "external":
89
+ if data[tag] is not None:
90
+ raise ValueError("Unit variant cannot carry non-null content")
91
+ return tag
92
+ if not isinstance(tag, str) or tag in known_tags:
93
+ return data
94
+ if (
95
+ representation == "adjacent"
96
+ and unknown_type is None
97
+ and fallback_tag is not None
98
+ and data.get(content_field) is not None
99
+ ):
100
+ raise ValueError("Fallback unit variant cannot carry non-null content")
101
+ if unknown_type is not None:
102
+ return unknown_type.model_construct(tag=tag, raw=data)
103
+ if fallback_tag is not None:
104
+ if representation == "external":
105
+ return fallback_tag
106
+ return {tag_field: fallback_tag}
107
+ return data
108
+
109
+
110
+ def parse_externally_tagged(
111
+ data: Any,
112
+ variants: dict[str, VariantHandler],
113
+ types: tuple[type[Any], ...],
114
+ enum_name: str,
115
+ *,
116
+ fallback_tag: str | None = None,
117
+ unknown_type: type[UnknownEnumValue] | None = None,
118
+ strict_unit: bool = False,
119
+ ) -> Any:
120
+ """Parse an externally tagged enum from ``{"Variant": value}`` format."""
121
+ if types and isinstance(data, types):
122
+ return data
123
+ data = classify_tagged_enum(
124
+ data,
125
+ frozenset(variants),
126
+ representation="external",
127
+ fallback_tag=fallback_tag,
128
+ unknown_type=unknown_type,
129
+ )
130
+ if isinstance(data, UnknownEnumValue):
131
+ return data
132
+ if isinstance(data, str) and data in variants:
133
+ handler = variants[data]
134
+ if handler == "_unit":
135
+ return data
136
+ if isinstance(data, dict):
137
+ if len(data) != 1:
138
+ raise ValueError("Externally tagged enum must have exactly one key")
139
+ key, value = next(iter(data.items()))
140
+ if key in variants:
141
+ handler = variants[key]
142
+ if handler == "_unit":
143
+ if strict_unit and value is not None:
144
+ raise ValueError(f"Unit variant {key} cannot carry a payload")
145
+ return key
146
+ if callable(handler):
147
+ return handler(value)
148
+ raise ValueError(f"Unknown variant for {enum_name}: {data}")
149
+
150
+
151
+ def serialize_externally_tagged(
152
+ root: Any,
153
+ serializers: VariantSerializers,
154
+ enum_name: str,
155
+ ) -> Any:
156
+ """Serialize an externally tagged enum to ``{"Variant": value}`` format."""
157
+ if isinstance(root, UnknownEnumValue):
158
+ return root.raw
159
+ for _variant_name, (check, serialize) in serializers.items():
160
+ if check(root):
161
+ return serialize(root)
162
+ raise ValueError(f"Cannot serialize {enum_name} variant: {type(root)}")
163
+
164
+
165
+ # Pydantic builds a JSON schema from a model's fields and never runs its
166
+ # custom validators or serializers. The generated classes below reshape the
167
+ # wire value in those hooks, so they also describe the wire shape through
168
+ # ``__get_pydantic_json_schema__``, which calls one of these helpers.
169
+
170
+ _WRAPPER_SCHEMAS = frozenset(
171
+ ("function-before", "function-after", "function-wrap", "definitions")
172
+ )
173
+
174
+
175
+ def _unwrap(schema: Any) -> Any:
176
+ while schema.get("type") in _WRAPPER_SCHEMAS and "schema" in schema:
177
+ schema = schema["schema"]
178
+ return schema
179
+
180
+
181
+ def _root_choices(core_schema: CoreSchema) -> list[CoreSchema]:
182
+ """The members of a ``RootModel[Union[...]]`` root type, in declaration order."""
183
+ schema = _unwrap(core_schema)
184
+ if schema.get("type") == "model":
185
+ schema = _unwrap(schema["schema"])
186
+ if schema.get("type") != "union":
187
+ return [schema]
188
+ return [c[0] if isinstance(c, tuple) else c for c in schema["choices"]]
189
+
190
+
191
+ def _member_json_schema(
192
+ handler: GetJsonSchemaHandler, member: CoreSchema
193
+ ) -> JsonSchemaValue:
194
+ """A union member's schema, as a ``$ref`` when the member is a model.
195
+
196
+ The handler inlines a schema it is given directly; a reference to the
197
+ definition that the enum's default schema already registered keeps it
198
+ shared.
199
+ """
200
+ ref = member.get("ref")
201
+ if ref is None:
202
+ return handler(member)
203
+ return handler({"type": "definition-ref", "schema_ref": ref})
204
+
205
+
206
+ def _resolve(
207
+ handler: GetJsonSchemaHandler, schema: JsonSchemaValue
208
+ ) -> JsonSchemaValue | None:
209
+ """The definition a ``$ref`` points at, or ``None`` if not generated yet."""
210
+ try:
211
+ return handler.resolve_ref_schema(schema)
212
+ except LookupError:
213
+ return None
214
+
215
+
216
+ def known_variants_json_schema(
217
+ core_schema: CoreSchema, handler: GetJsonSchemaHandler
218
+ ) -> JsonSchemaValue:
219
+ """Describe an open enum by its known variants, without the unknown case.
220
+
221
+ The unknown case is the last member of the generated root union.
222
+ """
223
+ schema = dict(handler.resolve_ref_schema(handler(core_schema)))
224
+ choices = _root_choices(core_schema)
225
+ unknown = _member_json_schema(handler, choices[-1])
226
+ for union in ("anyOf", "oneOf"):
227
+ members = schema.get(union)
228
+ if not members:
229
+ continue
230
+ known = [m for m in members if m != unknown and _UNKNOWN_VARIANT not in m]
231
+ if len(known) == 1:
232
+ del schema[union]
233
+ schema = {**known[0], **schema}
234
+ else:
235
+ schema[union] = known
236
+ break
237
+ return schema
238
+
239
+
240
+ def _model_metadata(
241
+ core_schema: CoreSchema, default: JsonSchemaValue
242
+ ) -> JsonSchemaValue:
243
+ """The enum class's own title and description.
244
+
245
+ The default schema of a RootModel over a single model is that model's
246
+ schema, so its title and description come from the class instead.
247
+ """
248
+ cls = _unwrap(core_schema).get("cls")
249
+ if cls is None:
250
+ return {k: default[k] for k in ("title", "description") if k in default}
251
+ metadata: JsonSchemaValue = {"title": cls.model_config.get("title") or cls.__name__}
252
+ doc = cls.__dict__.get("__doc__")
253
+ if doc:
254
+ metadata["description"] = inspect.cleandoc(doc)
255
+ return metadata
256
+
257
+
258
+ def _tuple_items(
259
+ handler: GetJsonSchemaHandler, variant: CoreSchema
260
+ ) -> list[Any] | None:
261
+ """The schemas of a tuple variant class's ``field_0``, ``field_1``, ..."""
262
+ resolved = _resolve(handler, _member_json_schema(handler, variant))
263
+ if resolved is None:
264
+ return None
265
+ properties = resolved.get("properties", {})
266
+ items = []
267
+ for index in range(len(properties)):
268
+ item = dict(properties[f"field_{index}"])
269
+ item.pop("title", None)
270
+ items.append(item)
271
+ return items
272
+
273
+
274
+ def externally_tagged_json_schema(
275
+ core_schema: CoreSchema,
276
+ handler: GetJsonSchemaHandler,
277
+ variants: Sequence[tuple[str, str]],
278
+ ) -> JsonSchemaValue:
279
+ """Describe an externally tagged enum as it appears on the wire.
280
+
281
+ ``variants`` lists ``(wire_name, shape)`` in the order of the RootModel's
282
+ union members, where ``shape`` is ``"unit"`` (``"Name"``), ``"newtype"``
283
+ (``{"Name": value}``), ``"tuple"`` (``{"Name": [a, b]}``) or ``"struct"``
284
+ (``{"Name": {...}}``). Members after the listed variants (the unknown case
285
+ of an open enum) are left out.
286
+ """
287
+ default = handler(core_schema)
288
+ choices = _root_choices(core_schema)
289
+ if len(choices) < len(variants):
290
+ return default
291
+ resolved = _resolve(handler, default) or {}
292
+ alternatives: list[JsonSchemaValue] = []
293
+ for (tag, shape), choice in zip(variants, choices, strict=False):
294
+ if shape == "unit":
295
+ alternatives.append({"const": tag, "type": "string"})
296
+ continue
297
+ if shape == "struct":
298
+ payload = _member_json_schema(handler, choice)
299
+ else:
300
+ items = _tuple_items(handler, choice)
301
+ if items is None:
302
+ return default
303
+ if shape == "newtype":
304
+ payload = items[0]
305
+ else:
306
+ payload = {
307
+ "type": "array",
308
+ "prefixItems": items,
309
+ "minItems": len(items),
310
+ "maxItems": len(items),
311
+ }
312
+ alternatives.append(
313
+ {"type": "object", "properties": {tag: payload}, "required": [tag]}
314
+ )
315
+ schema: JsonSchemaValue = (
316
+ alternatives[0] if len(alternatives) == 1 else {"oneOf": alternatives}
317
+ )
318
+ schema.update(_model_metadata(core_schema, resolved))
319
+ return schema
320
+
321
+
322
+ def _flattened_alternative(alternative: JsonSchemaValue) -> JsonSchemaValue:
323
+ # A flattened unit variant of an externally tagged enum is a key with a
324
+ # null value, not a bare string.
325
+ if alternative.get("type") == "string" and "const" in alternative:
326
+ tag = alternative["const"]
327
+ return {
328
+ "type": "object",
329
+ "properties": {tag: {"type": "null"}},
330
+ "required": [tag],
331
+ }
332
+ return alternative
333
+
334
+
335
+ def flattened_enum_json_schema(
336
+ core_schema: CoreSchema,
337
+ handler: GetJsonSchemaHandler,
338
+ field: str,
339
+ keys: Sequence[str],
340
+ *,
341
+ optional: bool = False,
342
+ ) -> JsonSchemaValue:
343
+ """Describe a struct whose enum field ``field`` is flattened into it.
344
+
345
+ The enum's keys sit next to the struct's own, so the schema is the
346
+ struct's other fields combined with the enum's schema. ``keys`` are the
347
+ keys that mark the enum as present (its tag, or the variant names of an
348
+ externally tagged enum); an ``optional`` group may also be absent.
349
+ """
350
+ parent = dict(handler.resolve_ref_schema(handler(core_schema)))
351
+ properties = dict(parent.get("properties", {}))
352
+ enum_schema = properties.pop(field, None)
353
+ if enum_schema is None:
354
+ return parent
355
+ parent["properties"] = properties
356
+ required = [name for name in parent.get("required", []) if name != field]
357
+ if required:
358
+ parent["required"] = required
359
+ else:
360
+ parent.pop("required", None)
361
+ if optional and "anyOf" in enum_schema:
362
+ present = [s for s in enum_schema["anyOf"] if s.get("type") != "null"]
363
+ enum_schema = present[0] if len(present) == 1 else {"anyOf": present}
364
+ resolved = _resolve(handler, enum_schema) or {}
365
+ # An externally tagged enum with only unit variants is a string enum.
366
+ tags = resolved.get("enum")
367
+ if tags is not None:
368
+ enum_schema = {
369
+ "oneOf": [
370
+ _flattened_alternative({"const": tag, "type": "string"}) for tag in tags
371
+ ]
372
+ }
373
+ else:
374
+ for union in ("oneOf", "anyOf"):
375
+ alternatives = resolved.get(union)
376
+ if alternatives and any(
377
+ _flattened_alternative(a) is not a for a in alternatives
378
+ ):
379
+ enum_schema = {union: [_flattened_alternative(a) for a in alternatives]}
380
+ break
381
+ if optional:
382
+ present_keys = [{"required": [key]} for key in keys]
383
+ absent = {
384
+ "not": present_keys[0]
385
+ if len(present_keys) == 1
386
+ else {"anyOf": present_keys}
387
+ }
388
+ enum_schema = {"anyOf": [enum_schema, absent]}
389
+ outer: JsonSchemaValue = {"allOf": [enum_schema, parent]}
390
+ for key in ("title", "description"):
391
+ if key in parent:
392
+ outer[key] = parent.pop(key)
393
+ return outer
@@ -0,0 +1,335 @@
1
+ """JSON schemas of models whose custom hooks reshape the wire value.
2
+
3
+ The classes below follow the shape of generated code. Pydantic never runs a
4
+ model's validators or serializers to build its JSON schema, so each class
5
+ describes its wire shape through ``__get_pydantic_json_schema__``.
6
+ """
7
+
8
+ # The classes spell unions as `Union[...]` and string enums as `(str, Enum)`,
9
+ # as generated code does.
10
+ # ruff: noqa: UP007, UP042
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from enum import Enum
16
+ from typing import Annotated, Any, Literal, Union
17
+
18
+ import jsonschema
19
+ import pytest
20
+ from pydantic import BaseModel, Field, RootModel, model_serializer, model_validator
21
+ from pydantic.json_schema import JsonSchemaMode
22
+
23
+ from reflectapi_runtime import (
24
+ UnknownEnumValue,
25
+ classify_tagged_enum,
26
+ externally_tagged_json_schema,
27
+ flattened_enum_json_schema,
28
+ known_variants_json_schema,
29
+ parse_externally_tagged,
30
+ serialize_externally_tagged,
31
+ )
32
+
33
+ MODES: tuple[JsonSchemaMode, ...] = ("validation", "serialization")
34
+
35
+
36
+ class PairVariant(BaseModel):
37
+ field_0: int
38
+ field_1: str
39
+
40
+
41
+ class CountVariant(BaseModel):
42
+ field_0: int
43
+
44
+
45
+ class NamedVariant(BaseModel):
46
+ name: str
47
+
48
+
49
+ class ShapeUnknown(UnknownEnumValue):
50
+ pass
51
+
52
+
53
+ class Shape(
54
+ RootModel[
55
+ Union[Literal["Empty"], PairVariant, CountVariant, NamedVariant, ShapeUnknown]
56
+ ]
57
+ ):
58
+ """Externally tagged enum"""
59
+
60
+ @model_validator(mode="before")
61
+ @classmethod
62
+ def _validate(cls, data: Any) -> Any:
63
+ return parse_externally_tagged(
64
+ data,
65
+ {
66
+ "Empty": "_unit",
67
+ "Pair": lambda v: PairVariant(field_0=v[0], field_1=v[1]),
68
+ "Count": lambda v: CountVariant(field_0=v),
69
+ "Named": lambda v: NamedVariant.model_validate(v),
70
+ },
71
+ (PairVariant, CountVariant, NamedVariant),
72
+ "Shape",
73
+ unknown_type=ShapeUnknown,
74
+ )
75
+
76
+ @model_serializer
77
+ def _serialize(self): # a declared return type would become the schema
78
+ return serialize_externally_tagged(
79
+ self.root,
80
+ {
81
+ "Empty": (lambda r: r == "Empty", lambda r: "Empty"),
82
+ "Pair": (
83
+ lambda r: isinstance(r, PairVariant),
84
+ lambda r: {"Pair": [r.field_0, r.field_1]},
85
+ ),
86
+ "Count": (
87
+ lambda r: isinstance(r, CountVariant),
88
+ lambda r: {"Count": r.field_0},
89
+ ),
90
+ "Named": (
91
+ lambda r: isinstance(r, NamedVariant),
92
+ lambda r: {"Named": r.model_dump(by_alias=True)},
93
+ ),
94
+ },
95
+ "Shape",
96
+ )
97
+
98
+ @classmethod
99
+ def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> Any:
100
+ return externally_tagged_json_schema(
101
+ core_schema,
102
+ handler,
103
+ (
104
+ ("Empty", "unit"),
105
+ ("Pair", "tuple"),
106
+ ("Count", "newtype"),
107
+ ("Named", "struct"),
108
+ ),
109
+ )
110
+
111
+
112
+ class Dog(BaseModel):
113
+ kind: Literal["Dog"] = "Dog"
114
+ breed: str
115
+
116
+
117
+ class Bird(BaseModel):
118
+ kind: Literal["Bird"] = "Bird"
119
+
120
+
121
+ class AnimalUnknown(UnknownEnumValue):
122
+ pass
123
+
124
+
125
+ AnimalKnown = Annotated[Union[Dog, Bird], Field(discriminator="kind")]
126
+
127
+
128
+ class Animal(RootModel[Union[AnimalKnown, AnimalUnknown]]):
129
+ root: Union[AnimalKnown, AnimalUnknown]
130
+
131
+ @model_validator(mode="before")
132
+ @classmethod
133
+ def _classify(cls, data: Any) -> Any:
134
+ return classify_tagged_enum(
135
+ data,
136
+ frozenset(("Dog", "Bird")),
137
+ representation="tagged",
138
+ tag_field="kind",
139
+ unknown_type=AnimalUnknown,
140
+ )
141
+
142
+ @classmethod
143
+ def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> Any:
144
+ return known_variants_json_schema(core_schema, handler)
145
+
146
+
147
+ class Envelope(BaseModel):
148
+ id: int
149
+ value: Animal
150
+
151
+ @model_validator(mode="before")
152
+ @classmethod
153
+ def _unflatten_enum(cls, data: Any) -> Any:
154
+ if not isinstance(data, dict) or "kind" not in data:
155
+ return data
156
+ parent = {key: value for key, value in data.items() if key == "id"}
157
+ parent["value"] = {key: value for key, value in data.items() if key != "id"}
158
+ return parent
159
+
160
+ @model_serializer(mode="wrap")
161
+ def _flatten_enum(self, handler: Any): # likewise
162
+ value = handler(self)
163
+ enum_value = value.pop("value")
164
+ return {**value, **enum_value}
165
+
166
+ @classmethod
167
+ def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> Any:
168
+ return flattened_enum_json_schema(core_schema, handler, "value", ("kind",))
169
+
170
+
171
+ class ClosedShape(RootModel[Union[Literal["Empty"], NamedVariant]]):
172
+ @model_validator(mode="before")
173
+ @classmethod
174
+ def _validate(cls, data: Any) -> Any:
175
+ return parse_externally_tagged(
176
+ data,
177
+ {"Empty": "_unit", "Named": lambda v: NamedVariant(**v)},
178
+ (NamedVariant,),
179
+ "ClosedShape",
180
+ )
181
+
182
+ @model_serializer
183
+ def _serialize(self): # a declared return type would become the schema
184
+ return serialize_externally_tagged(
185
+ self.root,
186
+ {
187
+ "Empty": (lambda r: r == "Empty", lambda r: "Empty"),
188
+ "Named": (
189
+ lambda r: isinstance(r, NamedVariant),
190
+ lambda r: {"Named": r.model_dump(by_alias=True)},
191
+ ),
192
+ },
193
+ "ClosedShape",
194
+ )
195
+
196
+ @classmethod
197
+ def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> Any:
198
+ return externally_tagged_json_schema(
199
+ core_schema, handler, (("Empty", "unit"), ("Named", "struct"))
200
+ )
201
+
202
+
203
+ class OptionalEnvelope(BaseModel):
204
+ id: int
205
+ value: ClosedShape | None = None
206
+
207
+ @model_validator(mode="before")
208
+ @classmethod
209
+ def _unflatten_enum(cls, data: Any) -> Any:
210
+ if not isinstance(data, dict):
211
+ return data
212
+ present = {"Empty", "Named"}.intersection(data)
213
+ parent = {key: value for key, value in data.items() if key == "id"}
214
+ if present:
215
+ key = next(iter(present))
216
+ parent["value"] = {key: data[key]}
217
+ return parent
218
+
219
+ @model_serializer(mode="wrap")
220
+ def _flatten_enum(self, handler: Any): # likewise
221
+ value = handler(self)
222
+ enum_value = value.pop("value", None)
223
+ if enum_value is None:
224
+ return value
225
+ if isinstance(enum_value, str):
226
+ enum_value = {enum_value: None}
227
+ return {**value, **enum_value}
228
+
229
+ @classmethod
230
+ def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> Any:
231
+ return flattened_enum_json_schema(
232
+ core_schema, handler, "value", ("Empty", "Named"), optional=True
233
+ )
234
+
235
+
236
+ def matches(schema: dict[str, Any], value: Any) -> bool:
237
+ return jsonschema.Draft202012Validator(schema).is_valid(value)
238
+
239
+
240
+ WIRE = {
241
+ Shape: ["Empty", {"Pair": [1, "one"]}, {"Count": 3}, {"Named": {"name": "n"}}],
242
+ Animal: [{"kind": "Dog", "breed": "corgi"}, {"kind": "Bird"}],
243
+ Envelope: [{"id": 1, "kind": "Dog", "breed": "corgi"}, {"id": 1, "kind": "Bird"}],
244
+ OptionalEnvelope: [
245
+ {"id": 1},
246
+ {"id": 1, "Empty": None},
247
+ {"id": 1, "Named": {"name": "n"}},
248
+ ],
249
+ }
250
+
251
+
252
+ @pytest.mark.parametrize("mode", MODES)
253
+ @pytest.mark.parametrize(
254
+ ("model", "wire"),
255
+ [(model, wire) for model, wires in WIRE.items() for wire in wires],
256
+ ids=lambda v: json.dumps(v) if not isinstance(v, type) else v.__name__,
257
+ )
258
+ def test_schema_accepts_the_wire_value(
259
+ model: type[BaseModel], wire: Any, mode: JsonSchemaMode
260
+ ) -> None:
261
+ assert json.loads(model.model_validate(wire).model_dump_json()) == wire
262
+ assert matches(model.model_json_schema(mode=mode), wire)
263
+
264
+
265
+ @pytest.mark.parametrize("mode", MODES)
266
+ @pytest.mark.parametrize(
267
+ ("model", "wire"),
268
+ [
269
+ (Shape, {"field_0": 1, "field_1": "one"}),
270
+ (Shape, {"Pair": [1]}),
271
+ (Shape, {"Count": {"field_0": 3}}),
272
+ (OptionalEnvelope, {"id": 1, "Empty": "Empty"}),
273
+ ],
274
+ )
275
+ def test_schema_rejects_the_python_shape(
276
+ model: type[BaseModel], wire: Any, mode: JsonSchemaMode
277
+ ) -> None:
278
+ assert not matches(model.model_json_schema(mode=mode), wire)
279
+
280
+
281
+ @pytest.mark.parametrize("mode", MODES)
282
+ def test_unknown_case_is_left_out(mode: JsonSchemaMode) -> None:
283
+ for model in (Shape, Animal, Envelope):
284
+ schema = json.dumps(model.model_json_schema(mode=mode))
285
+ assert "Unknown" not in schema
286
+ assert "x-reflectapi-unknown-variant" not in schema
287
+
288
+
289
+ def test_animal_keeps_discriminator() -> None:
290
+ schema = Animal.model_json_schema()
291
+ assert schema["discriminator"]["propertyName"] == "kind"
292
+ assert len(schema["oneOf"]) == 2
293
+
294
+
295
+ @pytest.mark.parametrize("mode", MODES)
296
+ def test_flattened_enum_has_no_nested_field(mode: JsonSchemaMode) -> None:
297
+ for model in (Envelope, OptionalEnvelope):
298
+ schema = model.model_json_schema(mode=mode)
299
+ enum, parent = schema["allOf"]
300
+ assert parent["properties"].keys() == {"id"}
301
+ assert parent["required"] == ["id"]
302
+
303
+
304
+ class Colour(str, Enum):
305
+ RED = "Red"
306
+
307
+
308
+ class PaintUnknown(UnknownEnumValue):
309
+ pass
310
+
311
+
312
+ # An open enum generated before `known_variants_json_schema`, without the hook
313
+ class Paint(RootModel[Union[Colour, PaintUnknown]]):
314
+ root: Union[Colour, PaintUnknown]
315
+
316
+ @model_validator(mode="before")
317
+ @classmethod
318
+ def _classify(cls, data: Any) -> Any:
319
+ return classify_tagged_enum(
320
+ data,
321
+ frozenset(("Red",)),
322
+ representation="external",
323
+ unknown_type=PaintUnknown,
324
+ unit_tags=frozenset(("Red",)),
325
+ )
326
+
327
+
328
+ @pytest.mark.parametrize("mode", MODES)
329
+ def test_unknown_case_matches_nothing_without_the_enum_hook(
330
+ mode: JsonSchemaMode,
331
+ ) -> None:
332
+ schema = Paint.model_json_schema(mode=mode)
333
+ assert matches(schema, "Red")
334
+ assert not matches(schema, "Blue")
335
+ assert not matches(schema, {"tag": "Blue", "raw": "Blue"})
@@ -251,3 +251,21 @@ def test_partial_subclass_does_not_require_any_in_callers_module(monkeypatch):
251
251
  assert caller.Request(required=None, optional=None).model_dump_json() == (
252
252
  '{"required":null,"optional":null}'
253
253
  )
254
+
255
+
256
+ class TestJsonSchema:
257
+ """The serializer must not hide the model from serialization-mode schemas."""
258
+
259
+ def test_serialization_schema_keeps_model_properties(self):
260
+ schema = PartialItem.model_json_schema(mode="serialization")
261
+ assert set(schema["properties"]) == {"identity", "snapshot"}
262
+ assert schema["required"] == ["identity"]
263
+
264
+ def test_serialization_schema_keeps_nested_partial_refs(self):
265
+ schema = PartialItem.model_json_schema(mode="serialization")
266
+ refs = json.dumps(schema["properties"]["snapshot"])
267
+ assert "PartialSnapshot" in refs
268
+ assert set(schema["$defs"]["PartialSnapshot"]["properties"]) == {
269
+ "name",
270
+ "description",
271
+ }
@@ -68,6 +68,13 @@ def test_parse_ignores_comments_and_unknown_fields() -> None:
68
68
  assert events == [SseEvent(data="kept")]
69
69
 
70
70
 
71
+ def test_parse_skips_keep_alive_comments_between_events() -> None:
72
+ # A server keeps an idle stream open with `:` comment blocks.
73
+ payload = "data: 1\n\n:\n\n:\n\ndata: 2\n\n"
74
+ events = list(parse_sse(_lines(payload)))
75
+ assert events == [SseEvent(data="1"), SseEvent(data="2")]
76
+
77
+
71
78
  def test_parse_field_without_colon_treated_as_empty_value() -> None:
72
79
  events = list(parse_sse(_lines("data\n\n")))
73
80
  assert events == [SseEvent(data="")]
@@ -1,141 +0,0 @@
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
- from pydantic import BaseModel, model_serializer, model_validator
9
-
10
- VariantHandler = Callable[[Any], Any] | str
11
- VariantSerializers = dict[str, tuple[Callable[[Any], bool], Callable[[Any], Any]]]
12
-
13
-
14
- class UnknownEnumValue(BaseModel):
15
- """An output enum case whose wire tag was not in the generated schema.
16
-
17
- Generated clients construct this after checking the wire discriminator. A
18
- raw object cannot be validated directly as this case, which keeps malformed
19
- known cases from silently falling through a Pydantic union.
20
- """
21
-
22
- tag: str
23
- raw: Any
24
-
25
- @model_validator(mode="before")
26
- @classmethod
27
- def _instance_only(cls, value: Any) -> Any:
28
- if not isinstance(value, cls):
29
- raise ValueError("Unknown enum cases are created from unfamiliar wire tags")
30
- return value
31
-
32
- @model_serializer(mode="plain")
33
- def _serialize_raw(self) -> Any:
34
- return self.raw
35
-
36
-
37
- def classify_tagged_enum(
38
- data: Any,
39
- known_tags: frozenset[str],
40
- *,
41
- representation: str,
42
- tag_field: str = "",
43
- content_field: str = "",
44
- fallback_tag: str | None = None,
45
- unknown_type: type[UnknownEnumValue] | None = None,
46
- unit_tags: frozenset[str] = frozenset(),
47
- ) -> Any:
48
- """Classify an unfamiliar valid tag without masking other validation errors."""
49
- if isinstance(data, UnknownEnumValue):
50
- return data
51
- if representation == "external":
52
- if isinstance(data, str):
53
- tag = data
54
- elif isinstance(data, dict) and len(data) == 1:
55
- tag = next(iter(data))
56
- else:
57
- return data
58
- elif isinstance(data, dict):
59
- if tag_field not in data or not isinstance(data[tag_field], str):
60
- raise ValueError("Tagged enum requires a string discriminator")
61
- tag = data[tag_field]
62
- else:
63
- return data
64
- if isinstance(tag, str) and tag in unit_tags and isinstance(data, dict):
65
- if representation == "adjacent" and data.get(content_field) is not None:
66
- raise ValueError("Unit variant cannot carry non-null content")
67
- if representation == "external":
68
- if data[tag] is not None:
69
- raise ValueError("Unit variant cannot carry non-null content")
70
- return tag
71
- if not isinstance(tag, str) or tag in known_tags:
72
- return data
73
- if (
74
- representation == "adjacent"
75
- and unknown_type is None
76
- and fallback_tag is not None
77
- and data.get(content_field) is not None
78
- ):
79
- raise ValueError("Fallback unit variant cannot carry non-null content")
80
- if unknown_type is not None:
81
- return unknown_type.model_construct(tag=tag, raw=data)
82
- if fallback_tag is not None:
83
- if representation == "external":
84
- return fallback_tag
85
- return {tag_field: fallback_tag}
86
- return data
87
-
88
-
89
- def parse_externally_tagged(
90
- data: Any,
91
- variants: dict[str, VariantHandler],
92
- types: tuple[type[Any], ...],
93
- enum_name: str,
94
- *,
95
- fallback_tag: str | None = None,
96
- unknown_type: type[UnknownEnumValue] | None = None,
97
- strict_unit: bool = False,
98
- ) -> Any:
99
- """Parse an externally tagged enum from ``{"Variant": value}`` format."""
100
- if types and isinstance(data, types):
101
- return data
102
- data = classify_tagged_enum(
103
- data,
104
- frozenset(variants),
105
- representation="external",
106
- fallback_tag=fallback_tag,
107
- unknown_type=unknown_type,
108
- )
109
- if isinstance(data, UnknownEnumValue):
110
- return data
111
- if isinstance(data, str) and data in variants:
112
- handler = variants[data]
113
- if handler == "_unit":
114
- return data
115
- if isinstance(data, dict):
116
- if len(data) != 1:
117
- raise ValueError("Externally tagged enum must have exactly one key")
118
- key, value = next(iter(data.items()))
119
- if key in variants:
120
- handler = variants[key]
121
- if handler == "_unit":
122
- if strict_unit and value is not None:
123
- raise ValueError(f"Unit variant {key} cannot carry a payload")
124
- return key
125
- if callable(handler):
126
- return handler(value)
127
- raise ValueError(f"Unknown variant for {enum_name}: {data}")
128
-
129
-
130
- def serialize_externally_tagged(
131
- root: Any,
132
- serializers: VariantSerializers,
133
- enum_name: str,
134
- ) -> Any:
135
- """Serialize an externally tagged enum to ``{"Variant": value}`` format."""
136
- if isinstance(root, UnknownEnumValue):
137
- return root.raw
138
- for _variant_name, (check, serialize) in serializers.items():
139
- if check(root):
140
- return serialize(root)
141
- raise ValueError(f"Cannot serialize {enum_name} variant: {type(root)}")