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.
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/PKG-INFO +3 -1
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/pyproject.toml +3 -1
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/__init__.py +7 -1
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/partial.py +7 -6
- reflectapi_runtime-0.23.0/src/reflectapi_runtime/serde.py +393 -0
- reflectapi_runtime-0.23.0/tests/test_json_schema.py +335 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_partial.py +18 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_sse.py +7 -0
- reflectapi_runtime-0.22.0/src/reflectapi_runtime/serde.py +0 -141
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/.gitignore +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/README.md +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/auth.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/batch.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/client.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/duration.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/exceptions.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/middleware.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/py.typed +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/response.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/sse.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/streaming.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/testing.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/transport.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/types.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/__init__.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_auth.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_auth_negative_cases.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_batch.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_client.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_codegen_regressions.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_edge_cases.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_enhanced_features.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_enum_forward_compatibility.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_exceptions.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_middleware.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_pydantic_serialization.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_request_field_presence.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_response.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_response_header_presence.py +0 -0
- {reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_streaming.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
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)}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/src/reflectapi_runtime/middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_enum_forward_compatibility.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_pydantic_serialization.py
RENAMED
|
File without changes
|
{reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_request_field_presence.py
RENAMED
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.22.0 → reflectapi_runtime-0.23.0}/tests/test_response_header_presence.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|