weakincentives 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
Potentially problematic release.
This version of weakincentives might be problematic. Click here for more details.
- weakincentives/__init__.py +15 -2
- weakincentives/adapters/__init__.py +30 -0
- weakincentives/adapters/core.py +85 -0
- weakincentives/adapters/openai.py +361 -0
- weakincentives/prompts/__init__.py +45 -0
- weakincentives/prompts/_types.py +27 -0
- weakincentives/prompts/errors.py +57 -0
- weakincentives/prompts/prompt.py +440 -0
- weakincentives/prompts/response_format.py +54 -0
- weakincentives/prompts/section.py +120 -0
- weakincentives/prompts/structured.py +140 -0
- weakincentives/prompts/text.py +89 -0
- weakincentives/prompts/tool.py +236 -0
- weakincentives/serde/__init__.py +31 -0
- weakincentives/serde/dataclass_serde.py +1016 -0
- weakincentives-0.2.0.dist-info/METADATA +173 -0
- weakincentives-0.2.0.dist-info/RECORD +20 -0
- weakincentives-0.1.0.dist-info/METADATA +0 -21
- weakincentives-0.1.0.dist-info/RECORD +0 -6
- {weakincentives-0.1.0.dist-info → weakincentives-0.2.0.dist-info}/WHEEL +0 -0
- {weakincentives-0.1.0.dist-info → weakincentives-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
# Copyright 2025 weak incentives
|
|
14
|
+
#
|
|
15
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
16
|
+
# you may not use this file except in compliance with the License.
|
|
17
|
+
# You may obtain a copy of the License at
|
|
18
|
+
#
|
|
19
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
20
|
+
#
|
|
21
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
22
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
23
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
24
|
+
# See the License for the specific language governing permissions and
|
|
25
|
+
# limitations under the License.
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import dataclasses
|
|
30
|
+
import re
|
|
31
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence, Sized
|
|
32
|
+
from dataclasses import MISSING
|
|
33
|
+
from datetime import date, datetime, time
|
|
34
|
+
from decimal import Decimal
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from re import Pattern
|
|
38
|
+
from typing import Any as _AnyType
|
|
39
|
+
from typing import Literal, Union, cast, get_args, get_origin, get_type_hints
|
|
40
|
+
from uuid import UUID
|
|
41
|
+
|
|
42
|
+
MISSING_SENTINEL: object = object()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _ExtrasDescriptor:
|
|
46
|
+
"""Descriptor storing extras for slotted dataclasses."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._store: dict[int, dict[str, object]] = {}
|
|
50
|
+
|
|
51
|
+
def __get__(
|
|
52
|
+
self, instance: object | None, owner: type[object]
|
|
53
|
+
) -> dict[str, object] | None:
|
|
54
|
+
if instance is None:
|
|
55
|
+
return None
|
|
56
|
+
return self._store.get(id(instance))
|
|
57
|
+
|
|
58
|
+
def __set__(self, instance: object, value: dict[str, object] | None) -> None:
|
|
59
|
+
key = id(instance)
|
|
60
|
+
if value is None:
|
|
61
|
+
self._store.pop(key, None)
|
|
62
|
+
else:
|
|
63
|
+
self._store[key] = dict(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_SLOTTED_EXTRAS: dict[type[object], _ExtrasDescriptor] = {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ordered_values(values: Iterable[object]) -> list[object]:
|
|
70
|
+
"""Return a deterministic list of metadata values."""
|
|
71
|
+
|
|
72
|
+
items = list(values)
|
|
73
|
+
if isinstance(values, (set, frozenset)):
|
|
74
|
+
try:
|
|
75
|
+
return sorted(items)
|
|
76
|
+
except TypeError:
|
|
77
|
+
return sorted(items, key=repr)
|
|
78
|
+
return items
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _set_extras(instance: object, extras: Mapping[str, object]) -> None:
|
|
82
|
+
"""Attach extras to an instance, handling slotted dataclasses."""
|
|
83
|
+
|
|
84
|
+
extras_dict = dict(extras)
|
|
85
|
+
try:
|
|
86
|
+
object.__setattr__(instance, "__extras__", extras_dict)
|
|
87
|
+
except AttributeError:
|
|
88
|
+
cls = instance.__class__
|
|
89
|
+
descriptor = _SLOTTED_EXTRAS.get(cls)
|
|
90
|
+
if descriptor is None:
|
|
91
|
+
descriptor = _ExtrasDescriptor()
|
|
92
|
+
_SLOTTED_EXTRAS[cls] = descriptor
|
|
93
|
+
cls.__extras__ = descriptor # type: ignore[attr-defined]
|
|
94
|
+
descriptor.__set__(instance, extras_dict)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclasses.dataclass(frozen=True)
|
|
98
|
+
class _ParseConfig:
|
|
99
|
+
extra: Literal["ignore", "forbid", "allow"]
|
|
100
|
+
coerce: bool
|
|
101
|
+
case_insensitive: bool
|
|
102
|
+
alias_generator: Callable[[str], str] | None
|
|
103
|
+
aliases: Mapping[str, str] | None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _merge_annotated_meta(
|
|
107
|
+
typ: object, meta: Mapping[str, object] | None
|
|
108
|
+
) -> tuple[object, dict[str, object]]:
|
|
109
|
+
merged: dict[str, object] = dict(meta or {})
|
|
110
|
+
base = typ
|
|
111
|
+
while getattr(base, "__metadata__", None) is not None:
|
|
112
|
+
args = get_args(base)
|
|
113
|
+
if not args:
|
|
114
|
+
break
|
|
115
|
+
base = args[0]
|
|
116
|
+
for extra in args[1:]:
|
|
117
|
+
if isinstance(extra, Mapping):
|
|
118
|
+
merged.update(extra)
|
|
119
|
+
return base, merged
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _bool_from_str(value: str) -> bool:
|
|
123
|
+
lowered = value.strip().lower()
|
|
124
|
+
truthy = {"true", "1", "yes", "on"}
|
|
125
|
+
falsy = {"false", "0", "no", "off"}
|
|
126
|
+
if lowered in truthy:
|
|
127
|
+
return True
|
|
128
|
+
if lowered in falsy:
|
|
129
|
+
return False
|
|
130
|
+
raise TypeError(f"Cannot interpret '{value}' as boolean")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _apply_constraints(value: object, meta: Mapping[str, object], path: str) -> object:
|
|
134
|
+
if not meta:
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
result = value
|
|
138
|
+
if isinstance(result, str):
|
|
139
|
+
if meta.get("strip"):
|
|
140
|
+
result = result.strip()
|
|
141
|
+
if meta.get("lower") or meta.get("lowercase"):
|
|
142
|
+
result = result.lower()
|
|
143
|
+
if meta.get("upper") or meta.get("uppercase"):
|
|
144
|
+
result = result.upper()
|
|
145
|
+
|
|
146
|
+
def _normalize_option(option: object) -> object:
|
|
147
|
+
if isinstance(result, str) and isinstance(option, str):
|
|
148
|
+
candidate: str = option
|
|
149
|
+
if meta.get("strip"):
|
|
150
|
+
candidate = candidate.strip()
|
|
151
|
+
if meta.get("lower") or meta.get("lowercase"):
|
|
152
|
+
candidate = candidate.lower()
|
|
153
|
+
if meta.get("upper") or meta.get("uppercase"):
|
|
154
|
+
candidate = candidate.upper()
|
|
155
|
+
return candidate
|
|
156
|
+
return option
|
|
157
|
+
|
|
158
|
+
def _fail(message: str) -> None:
|
|
159
|
+
raise ValueError(f"{path}: {message}")
|
|
160
|
+
|
|
161
|
+
numeric_value = result
|
|
162
|
+
if isinstance(numeric_value, (int, float, Decimal)):
|
|
163
|
+
numeric = numeric_value
|
|
164
|
+
minimum_candidate = meta.get("ge", meta.get("minimum"))
|
|
165
|
+
if (
|
|
166
|
+
isinstance(minimum_candidate, (int, float, Decimal))
|
|
167
|
+
and numeric < minimum_candidate
|
|
168
|
+
):
|
|
169
|
+
_fail(f"must be >= {minimum_candidate}")
|
|
170
|
+
exclusive_min_candidate = meta.get("gt", meta.get("exclusiveMinimum"))
|
|
171
|
+
if (
|
|
172
|
+
isinstance(exclusive_min_candidate, (int, float, Decimal))
|
|
173
|
+
and numeric <= exclusive_min_candidate
|
|
174
|
+
):
|
|
175
|
+
_fail(f"must be > {exclusive_min_candidate}")
|
|
176
|
+
maximum_candidate = meta.get("le", meta.get("maximum"))
|
|
177
|
+
if (
|
|
178
|
+
isinstance(maximum_candidate, (int, float, Decimal))
|
|
179
|
+
and numeric > maximum_candidate
|
|
180
|
+
):
|
|
181
|
+
_fail(f"must be <= {maximum_candidate}")
|
|
182
|
+
exclusive_max_candidate = meta.get("lt", meta.get("exclusiveMaximum"))
|
|
183
|
+
if (
|
|
184
|
+
isinstance(exclusive_max_candidate, (int, float, Decimal))
|
|
185
|
+
and numeric >= exclusive_max_candidate
|
|
186
|
+
):
|
|
187
|
+
_fail(f"must be < {exclusive_max_candidate}")
|
|
188
|
+
|
|
189
|
+
if isinstance(result, Sized):
|
|
190
|
+
min_length_candidate = meta.get("min_length", meta.get("minLength"))
|
|
191
|
+
if isinstance(min_length_candidate, int) and len(result) < min_length_candidate:
|
|
192
|
+
_fail(f"length must be >= {min_length_candidate}")
|
|
193
|
+
max_length_candidate = meta.get("max_length", meta.get("maxLength"))
|
|
194
|
+
if isinstance(max_length_candidate, int) and len(result) > max_length_candidate:
|
|
195
|
+
_fail(f"length must be <= {max_length_candidate}")
|
|
196
|
+
|
|
197
|
+
pattern = meta.get("regex", meta.get("pattern"))
|
|
198
|
+
if isinstance(pattern, str) and isinstance(result, str):
|
|
199
|
+
if not re.search(pattern, result):
|
|
200
|
+
_fail(f"does not match pattern {pattern}")
|
|
201
|
+
elif isinstance(pattern, Pattern) and isinstance(result, str):
|
|
202
|
+
compiled_pattern = cast(Pattern[str], pattern)
|
|
203
|
+
if not compiled_pattern.search(result):
|
|
204
|
+
_fail(f"does not match pattern {pattern}")
|
|
205
|
+
|
|
206
|
+
members = meta.get("in") or meta.get("enum")
|
|
207
|
+
if isinstance(members, Iterable) and not isinstance(members, (str, bytes)):
|
|
208
|
+
options = _ordered_values(members)
|
|
209
|
+
normalized_options = [_normalize_option(option) for option in options]
|
|
210
|
+
if result not in normalized_options:
|
|
211
|
+
_fail(f"must be one of {normalized_options}")
|
|
212
|
+
|
|
213
|
+
not_members = meta.get("not_in")
|
|
214
|
+
if isinstance(not_members, Iterable) and not isinstance(not_members, (str, bytes)):
|
|
215
|
+
forbidden = _ordered_values(not_members)
|
|
216
|
+
normalized_forbidden = [_normalize_option(option) for option in forbidden]
|
|
217
|
+
if result in normalized_forbidden:
|
|
218
|
+
_fail(f"may not be one of {normalized_forbidden}")
|
|
219
|
+
|
|
220
|
+
validators = meta.get("validators", meta.get("validate"))
|
|
221
|
+
if validators:
|
|
222
|
+
callables: Iterable[Callable[[object], object]]
|
|
223
|
+
if isinstance(validators, Iterable) and not isinstance(
|
|
224
|
+
validators, (str, bytes)
|
|
225
|
+
):
|
|
226
|
+
callables = cast(Iterable[Callable[[object], object]], validators)
|
|
227
|
+
else:
|
|
228
|
+
callables = (cast(Callable[[object], object], validators),)
|
|
229
|
+
for validator in callables:
|
|
230
|
+
try:
|
|
231
|
+
result = validator(result)
|
|
232
|
+
except (TypeError, ValueError) as error:
|
|
233
|
+
raise type(error)(f"{path}: {error}") from error
|
|
234
|
+
except Exception as error: # pragma: no cover - defensive
|
|
235
|
+
raise ValueError(f"{path}: validator raised {error!r}") from error
|
|
236
|
+
|
|
237
|
+
converter = meta.get("convert", meta.get("transform"))
|
|
238
|
+
if converter:
|
|
239
|
+
try:
|
|
240
|
+
result = converter(result)
|
|
241
|
+
except (TypeError, ValueError) as error:
|
|
242
|
+
raise type(error)(f"{path}: {error}") from error
|
|
243
|
+
except Exception as error: # pragma: no cover - defensive
|
|
244
|
+
raise ValueError(f"{path}: converter raised {error!r}") from error
|
|
245
|
+
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _coerce_to_type(
|
|
250
|
+
value: object,
|
|
251
|
+
typ: object,
|
|
252
|
+
meta: Mapping[str, object] | None,
|
|
253
|
+
path: str,
|
|
254
|
+
config: _ParseConfig,
|
|
255
|
+
) -> object:
|
|
256
|
+
base_type, merged_meta = _merge_annotated_meta(typ, meta)
|
|
257
|
+
origin = get_origin(base_type)
|
|
258
|
+
type_name = getattr(base_type, "__name__", type(base_type).__name__)
|
|
259
|
+
|
|
260
|
+
if base_type in {object, _AnyType}:
|
|
261
|
+
return _apply_constraints(value, merged_meta, path)
|
|
262
|
+
|
|
263
|
+
if origin is Union:
|
|
264
|
+
if (
|
|
265
|
+
config.coerce
|
|
266
|
+
and isinstance(value, str)
|
|
267
|
+
and value.strip() == ""
|
|
268
|
+
and any(arg is type(None) for arg in get_args(base_type))
|
|
269
|
+
):
|
|
270
|
+
return _apply_constraints(None, merged_meta, path)
|
|
271
|
+
last_error: Exception | None = None
|
|
272
|
+
for arg in get_args(base_type):
|
|
273
|
+
if arg is type(None):
|
|
274
|
+
if value is None:
|
|
275
|
+
return _apply_constraints(None, merged_meta, path)
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
coerced = _coerce_to_type(value, arg, None, path, config)
|
|
279
|
+
except (TypeError, ValueError) as error:
|
|
280
|
+
last_error = error
|
|
281
|
+
continue
|
|
282
|
+
return _apply_constraints(coerced, merged_meta, path)
|
|
283
|
+
if last_error is not None:
|
|
284
|
+
message = str(last_error)
|
|
285
|
+
if message.startswith(f"{path}:") or message.startswith(f"{path}."):
|
|
286
|
+
raise last_error
|
|
287
|
+
if isinstance(last_error, TypeError):
|
|
288
|
+
raise TypeError(f"{path}: {message}") from last_error
|
|
289
|
+
raise ValueError(f"{path}: {message}") from last_error
|
|
290
|
+
raise TypeError(f"{path}: no matching type in Union")
|
|
291
|
+
|
|
292
|
+
if base_type is type(None):
|
|
293
|
+
if value is not None:
|
|
294
|
+
raise TypeError(f"{path}: expected None")
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
if value is None:
|
|
298
|
+
raise TypeError(f"{path}: value cannot be None")
|
|
299
|
+
|
|
300
|
+
if origin is Literal:
|
|
301
|
+
literals = get_args(base_type)
|
|
302
|
+
last_literal_error: Exception | None = None
|
|
303
|
+
for literal in literals:
|
|
304
|
+
if value == literal:
|
|
305
|
+
return _apply_constraints(literal, merged_meta, path)
|
|
306
|
+
if config.coerce:
|
|
307
|
+
literal_type = type(literal)
|
|
308
|
+
try:
|
|
309
|
+
if isinstance(literal, bool) and isinstance(value, str):
|
|
310
|
+
coerced_literal = _bool_from_str(value)
|
|
311
|
+
else:
|
|
312
|
+
coerced_literal = literal_type(value)
|
|
313
|
+
except (TypeError, ValueError) as error:
|
|
314
|
+
last_literal_error = error
|
|
315
|
+
continue
|
|
316
|
+
if coerced_literal == literal:
|
|
317
|
+
return _apply_constraints(literal, merged_meta, path)
|
|
318
|
+
if last_literal_error is not None:
|
|
319
|
+
raise type(last_literal_error)(
|
|
320
|
+
f"{path}: {last_literal_error}"
|
|
321
|
+
) from last_literal_error
|
|
322
|
+
raise ValueError(f"{path}: expected one of {list(literals)}")
|
|
323
|
+
|
|
324
|
+
if dataclasses.is_dataclass(base_type):
|
|
325
|
+
dataclass_type = base_type if isinstance(base_type, type) else type(base_type)
|
|
326
|
+
if isinstance(value, dataclass_type):
|
|
327
|
+
return _apply_constraints(value, merged_meta, path)
|
|
328
|
+
if not isinstance(value, Mapping):
|
|
329
|
+
type_name = getattr(
|
|
330
|
+
dataclass_type, "__name__", dataclass_type.__class__.__name__
|
|
331
|
+
)
|
|
332
|
+
raise TypeError(f"{path}: expected mapping for dataclass {type_name}")
|
|
333
|
+
try:
|
|
334
|
+
parsed = parse(
|
|
335
|
+
cast(type[object], dataclass_type),
|
|
336
|
+
cast(Mapping[str, object], value),
|
|
337
|
+
extra=config.extra,
|
|
338
|
+
coerce=config.coerce,
|
|
339
|
+
case_insensitive=config.case_insensitive,
|
|
340
|
+
alias_generator=config.alias_generator,
|
|
341
|
+
aliases=config.aliases,
|
|
342
|
+
)
|
|
343
|
+
except (TypeError, ValueError) as error:
|
|
344
|
+
message = str(error)
|
|
345
|
+
if ":" in message:
|
|
346
|
+
prefix, suffix = message.split(":", 1)
|
|
347
|
+
if " " not in prefix:
|
|
348
|
+
message = f"{path}.{prefix}:{suffix}"
|
|
349
|
+
else:
|
|
350
|
+
message = f"{path}: {message}"
|
|
351
|
+
else:
|
|
352
|
+
message = f"{path}: {message}"
|
|
353
|
+
raise type(error)(message) from error
|
|
354
|
+
return _apply_constraints(parsed, merged_meta, path)
|
|
355
|
+
|
|
356
|
+
if origin in {list, Sequence, tuple, set}:
|
|
357
|
+
is_sequence_like = isinstance(value, Sequence) and not isinstance(
|
|
358
|
+
value, (str, bytes, bytearray)
|
|
359
|
+
)
|
|
360
|
+
if origin in {list, Sequence} and not is_sequence_like:
|
|
361
|
+
if config.coerce and isinstance(value, str):
|
|
362
|
+
value = [value]
|
|
363
|
+
else:
|
|
364
|
+
raise TypeError(f"{path}: expected sequence")
|
|
365
|
+
if origin is set and not isinstance(value, (set, list, tuple)):
|
|
366
|
+
if config.coerce:
|
|
367
|
+
if isinstance(value, str):
|
|
368
|
+
value = [value]
|
|
369
|
+
elif isinstance(value, Iterable):
|
|
370
|
+
value = list(value)
|
|
371
|
+
else:
|
|
372
|
+
raise TypeError(f"{path}: expected set")
|
|
373
|
+
else:
|
|
374
|
+
raise TypeError(f"{path}: expected set")
|
|
375
|
+
if origin is tuple and not is_sequence_like:
|
|
376
|
+
if config.coerce and isinstance(value, str):
|
|
377
|
+
value = [value]
|
|
378
|
+
else:
|
|
379
|
+
raise TypeError(f"{path}: expected tuple")
|
|
380
|
+
|
|
381
|
+
if isinstance(value, str): # pragma: no cover - handled by earlier coercion
|
|
382
|
+
items = [value]
|
|
383
|
+
elif isinstance(value, Iterable):
|
|
384
|
+
items = list(value)
|
|
385
|
+
else: # pragma: no cover - defensive guard
|
|
386
|
+
raise TypeError(f"{path}: expected iterable")
|
|
387
|
+
args = get_args(base_type)
|
|
388
|
+
coerced_items: list[object] = []
|
|
389
|
+
if (
|
|
390
|
+
origin is tuple
|
|
391
|
+
and args
|
|
392
|
+
and args[-1] is not Ellipsis
|
|
393
|
+
and len(args) != len(items)
|
|
394
|
+
):
|
|
395
|
+
raise ValueError(f"{path}: expected {len(args)} items")
|
|
396
|
+
for index, item in enumerate(items):
|
|
397
|
+
item_path = f"{path}[{index}]"
|
|
398
|
+
if origin is tuple and args:
|
|
399
|
+
item_type = args[0] if args[-1] is Ellipsis else args[index]
|
|
400
|
+
else:
|
|
401
|
+
item_type = args[0] if args else object
|
|
402
|
+
coerced_items.append(
|
|
403
|
+
_coerce_to_type(item, item_type, None, item_path, config)
|
|
404
|
+
)
|
|
405
|
+
if origin is set:
|
|
406
|
+
value_out: object = set(coerced_items)
|
|
407
|
+
elif origin is tuple:
|
|
408
|
+
value_out = tuple(coerced_items)
|
|
409
|
+
else:
|
|
410
|
+
value_out = list(coerced_items)
|
|
411
|
+
return _apply_constraints(value_out, merged_meta, path)
|
|
412
|
+
|
|
413
|
+
if origin is dict or origin is Mapping:
|
|
414
|
+
if not isinstance(value, Mapping):
|
|
415
|
+
raise TypeError(f"{path}: expected mapping")
|
|
416
|
+
key_type, value_type = (
|
|
417
|
+
get_args(base_type) if get_args(base_type) else (object, object)
|
|
418
|
+
)
|
|
419
|
+
result_dict: dict[object, object] = {}
|
|
420
|
+
for key, item in value.items():
|
|
421
|
+
coerced_key = _coerce_to_type(key, key_type, None, f"{path} keys", config)
|
|
422
|
+
coerced_value = _coerce_to_type(
|
|
423
|
+
item, value_type, None, f"{path}[{coerced_key}]", config
|
|
424
|
+
)
|
|
425
|
+
result_dict[coerced_key] = coerced_value
|
|
426
|
+
return _apply_constraints(result_dict, merged_meta, path)
|
|
427
|
+
|
|
428
|
+
if isinstance(base_type, type) and issubclass(base_type, Enum):
|
|
429
|
+
if isinstance(value, base_type):
|
|
430
|
+
enum_value = value
|
|
431
|
+
elif config.coerce:
|
|
432
|
+
try:
|
|
433
|
+
enum_value = base_type[value] # type: ignore[index]
|
|
434
|
+
except KeyError:
|
|
435
|
+
try:
|
|
436
|
+
enum_value = base_type(value) # type: ignore[call-arg]
|
|
437
|
+
except ValueError as error:
|
|
438
|
+
raise ValueError(f"{path}: invalid enum value {value!r}") from error
|
|
439
|
+
except TypeError:
|
|
440
|
+
try:
|
|
441
|
+
enum_value = base_type(value)
|
|
442
|
+
except ValueError as error:
|
|
443
|
+
raise ValueError(f"{path}: invalid enum value {value!r}") from error
|
|
444
|
+
else:
|
|
445
|
+
raise TypeError(f"{path}: expected {type_name}")
|
|
446
|
+
return _apply_constraints(enum_value, merged_meta, path)
|
|
447
|
+
|
|
448
|
+
if base_type is bool:
|
|
449
|
+
if isinstance(value, bool):
|
|
450
|
+
return _apply_constraints(value, merged_meta, path)
|
|
451
|
+
if config.coerce and isinstance(value, str):
|
|
452
|
+
try:
|
|
453
|
+
coerced_bool = _bool_from_str(value)
|
|
454
|
+
except TypeError as error:
|
|
455
|
+
raise TypeError(f"{path}: {error}") from error
|
|
456
|
+
return _apply_constraints(coerced_bool, merged_meta, path)
|
|
457
|
+
if config.coerce and isinstance(value, (int, float)):
|
|
458
|
+
return _apply_constraints(bool(value), merged_meta, path)
|
|
459
|
+
raise TypeError(f"{path}: expected bool")
|
|
460
|
+
|
|
461
|
+
if base_type in {int, float, str, Decimal, UUID, Path, datetime, date, time}:
|
|
462
|
+
if isinstance(value, base_type):
|
|
463
|
+
return _apply_constraints(value, merged_meta, path)
|
|
464
|
+
if not config.coerce:
|
|
465
|
+
raise TypeError(f"{path}: expected {type_name}")
|
|
466
|
+
try:
|
|
467
|
+
if base_type is int:
|
|
468
|
+
coerced_value = int(value)
|
|
469
|
+
elif base_type is float:
|
|
470
|
+
coerced_value = float(value)
|
|
471
|
+
elif base_type is str:
|
|
472
|
+
coerced_value = str(value)
|
|
473
|
+
elif base_type is Decimal:
|
|
474
|
+
coerced_value = Decimal(str(value))
|
|
475
|
+
elif base_type is UUID:
|
|
476
|
+
coerced_value = UUID(str(value))
|
|
477
|
+
elif base_type is Path:
|
|
478
|
+
coerced_value = Path(str(value))
|
|
479
|
+
elif base_type is datetime:
|
|
480
|
+
coerced_value = datetime.fromisoformat(str(value))
|
|
481
|
+
elif base_type is date:
|
|
482
|
+
coerced_value = date.fromisoformat(str(value))
|
|
483
|
+
elif base_type is time:
|
|
484
|
+
coerced_value = time.fromisoformat(str(value))
|
|
485
|
+
except Exception as error:
|
|
486
|
+
raise TypeError(
|
|
487
|
+
f"{path}: unable to coerce {value!r} to {type_name}"
|
|
488
|
+
) from error
|
|
489
|
+
return _apply_constraints(coerced_value, merged_meta, path)
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
coerced = base_type(value) # type: ignore[call-arg]
|
|
493
|
+
except Exception as error:
|
|
494
|
+
raise type(error)(str(error)) from error
|
|
495
|
+
return _apply_constraints(coerced, merged_meta, path)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _find_key(
|
|
499
|
+
data: Mapping[str, object], name: str, alias: str | None, case_insensitive: bool
|
|
500
|
+
) -> str | None:
|
|
501
|
+
candidates = [alias, name]
|
|
502
|
+
for candidate in candidates:
|
|
503
|
+
if candidate is None:
|
|
504
|
+
continue
|
|
505
|
+
if candidate in data:
|
|
506
|
+
return candidate
|
|
507
|
+
if not case_insensitive:
|
|
508
|
+
return None
|
|
509
|
+
lowered_map: dict[str, str] = {}
|
|
510
|
+
for key in data:
|
|
511
|
+
if isinstance(key, str):
|
|
512
|
+
lowered_map.setdefault(key.lower(), key)
|
|
513
|
+
for candidate in candidates:
|
|
514
|
+
if candidate is None or not isinstance(candidate, str):
|
|
515
|
+
continue
|
|
516
|
+
lowered = candidate.lower()
|
|
517
|
+
if lowered in lowered_map:
|
|
518
|
+
return lowered_map[lowered]
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _serialize(
|
|
523
|
+
value: object,
|
|
524
|
+
*,
|
|
525
|
+
by_alias: bool,
|
|
526
|
+
exclude_none: bool,
|
|
527
|
+
alias_generator: Callable[[str], str] | None,
|
|
528
|
+
) -> object:
|
|
529
|
+
if value is None:
|
|
530
|
+
return MISSING_SENTINEL if exclude_none else None
|
|
531
|
+
if dataclasses.is_dataclass(value):
|
|
532
|
+
return dump(
|
|
533
|
+
value,
|
|
534
|
+
by_alias=by_alias,
|
|
535
|
+
exclude_none=exclude_none,
|
|
536
|
+
computed=False,
|
|
537
|
+
alias_generator=alias_generator,
|
|
538
|
+
)
|
|
539
|
+
if isinstance(value, Enum):
|
|
540
|
+
return value.value
|
|
541
|
+
if isinstance(value, (datetime, date, time)):
|
|
542
|
+
return value.isoformat()
|
|
543
|
+
if isinstance(value, (UUID, Decimal, Path)):
|
|
544
|
+
return str(value)
|
|
545
|
+
if isinstance(value, Mapping):
|
|
546
|
+
serialized: dict[object, object] = {}
|
|
547
|
+
for key, item in value.items():
|
|
548
|
+
item_value = _serialize(
|
|
549
|
+
item,
|
|
550
|
+
by_alias=by_alias,
|
|
551
|
+
exclude_none=exclude_none,
|
|
552
|
+
alias_generator=alias_generator,
|
|
553
|
+
)
|
|
554
|
+
if item_value is MISSING_SENTINEL:
|
|
555
|
+
continue
|
|
556
|
+
serialized[key] = item_value
|
|
557
|
+
return serialized
|
|
558
|
+
if isinstance(value, set):
|
|
559
|
+
items = [
|
|
560
|
+
item
|
|
561
|
+
for item in (
|
|
562
|
+
_serialize(
|
|
563
|
+
member,
|
|
564
|
+
by_alias=by_alias,
|
|
565
|
+
exclude_none=exclude_none,
|
|
566
|
+
alias_generator=alias_generator,
|
|
567
|
+
)
|
|
568
|
+
for member in value
|
|
569
|
+
)
|
|
570
|
+
if item is not MISSING_SENTINEL
|
|
571
|
+
]
|
|
572
|
+
try:
|
|
573
|
+
return sorted(items)
|
|
574
|
+
except TypeError:
|
|
575
|
+
return items
|
|
576
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
577
|
+
items = []
|
|
578
|
+
for item in value:
|
|
579
|
+
item_value = _serialize(
|
|
580
|
+
item,
|
|
581
|
+
by_alias=by_alias,
|
|
582
|
+
exclude_none=exclude_none,
|
|
583
|
+
alias_generator=alias_generator,
|
|
584
|
+
)
|
|
585
|
+
if item_value is MISSING_SENTINEL:
|
|
586
|
+
continue
|
|
587
|
+
items.append(item_value)
|
|
588
|
+
return items
|
|
589
|
+
return value
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def parse[T](
|
|
593
|
+
cls: type[T],
|
|
594
|
+
data: Mapping[str, object],
|
|
595
|
+
*,
|
|
596
|
+
extra: Literal["ignore", "forbid", "allow"] = "ignore",
|
|
597
|
+
coerce: bool = True,
|
|
598
|
+
case_insensitive: bool = False,
|
|
599
|
+
alias_generator: Callable[[str], str] | None = None,
|
|
600
|
+
aliases: Mapping[str, str] | None = None,
|
|
601
|
+
) -> T:
|
|
602
|
+
"""Parse a mapping into a dataclass instance.
|
|
603
|
+
|
|
604
|
+
Parameters
|
|
605
|
+
----------
|
|
606
|
+
cls:
|
|
607
|
+
Dataclass type to instantiate.
|
|
608
|
+
data:
|
|
609
|
+
Mapping payload describing the instance.
|
|
610
|
+
|
|
611
|
+
Returns
|
|
612
|
+
-------
|
|
613
|
+
T
|
|
614
|
+
Parsed dataclass instance after type coercion and validation.
|
|
615
|
+
|
|
616
|
+
Examples
|
|
617
|
+
--------
|
|
618
|
+
>>> from dataclasses import dataclass
|
|
619
|
+
>>> @dataclass
|
|
620
|
+
... class Example:
|
|
621
|
+
... name: str
|
|
622
|
+
>>> parse(Example, {"name": "Ada"})
|
|
623
|
+
Example(name='Ada')
|
|
624
|
+
"""
|
|
625
|
+
if not dataclasses.is_dataclass(cls) or not isinstance(cls, type):
|
|
626
|
+
raise TypeError("parse() requires a dataclass type")
|
|
627
|
+
if not isinstance(data, Mapping):
|
|
628
|
+
raise TypeError("parse() requires a mapping input")
|
|
629
|
+
if extra not in {"ignore", "forbid", "allow"}:
|
|
630
|
+
raise ValueError("extra must be one of 'ignore', 'forbid', or 'allow'")
|
|
631
|
+
|
|
632
|
+
config = _ParseConfig(
|
|
633
|
+
extra=extra,
|
|
634
|
+
coerce=coerce,
|
|
635
|
+
case_insensitive=case_insensitive,
|
|
636
|
+
alias_generator=alias_generator,
|
|
637
|
+
aliases=aliases,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
type_hints = get_type_hints(cls, include_extras=True)
|
|
641
|
+
kwargs: dict[str, object] = {}
|
|
642
|
+
used_keys: set[str] = set()
|
|
643
|
+
|
|
644
|
+
for field in dataclasses.fields(cls):
|
|
645
|
+
if not field.init:
|
|
646
|
+
continue
|
|
647
|
+
field_meta = dict(field.metadata) if field.metadata is not None else {}
|
|
648
|
+
field_alias = None
|
|
649
|
+
if aliases and field.name in aliases:
|
|
650
|
+
field_alias = aliases[field.name]
|
|
651
|
+
elif (alias := field_meta.get("alias")) is not None:
|
|
652
|
+
field_alias = alias
|
|
653
|
+
elif alias_generator is not None:
|
|
654
|
+
field_alias = alias_generator(field.name)
|
|
655
|
+
|
|
656
|
+
key = _find_key(data, field.name, field_alias, case_insensitive)
|
|
657
|
+
if key is None:
|
|
658
|
+
if field.default is MISSING and field.default_factory is MISSING:
|
|
659
|
+
raise ValueError(f"Missing required field: '{field.name}'")
|
|
660
|
+
continue
|
|
661
|
+
used_keys.add(key)
|
|
662
|
+
raw_value = data[key]
|
|
663
|
+
field_type = type_hints.get(field.name, field.type)
|
|
664
|
+
try:
|
|
665
|
+
value = _coerce_to_type(
|
|
666
|
+
raw_value, field_type, field_meta, field.name, config
|
|
667
|
+
)
|
|
668
|
+
except (TypeError, ValueError) as error:
|
|
669
|
+
raise type(error)(str(error)) from error
|
|
670
|
+
kwargs[field.name] = value
|
|
671
|
+
|
|
672
|
+
instance = cls(**kwargs)
|
|
673
|
+
|
|
674
|
+
extras = {key: data[key] for key in data if key not in used_keys}
|
|
675
|
+
if extras:
|
|
676
|
+
if extra == "forbid":
|
|
677
|
+
raise ValueError(f"Extra keys not permitted: {list(extras.keys())}")
|
|
678
|
+
if extra == "allow":
|
|
679
|
+
if hasattr(instance, "__dict__"):
|
|
680
|
+
for key, value in extras.items():
|
|
681
|
+
object.__setattr__(instance, key, value)
|
|
682
|
+
else:
|
|
683
|
+
_set_extras(instance, extras)
|
|
684
|
+
|
|
685
|
+
if extra == "allow" and not extras:
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
validator = getattr(instance, "__validate__", None)
|
|
689
|
+
if callable(validator):
|
|
690
|
+
validator()
|
|
691
|
+
post_validator = getattr(instance, "__post_validate__", None)
|
|
692
|
+
if callable(post_validator):
|
|
693
|
+
post_validator()
|
|
694
|
+
|
|
695
|
+
return instance
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def dump(
|
|
699
|
+
obj: object,
|
|
700
|
+
*,
|
|
701
|
+
by_alias: bool = True,
|
|
702
|
+
exclude_none: bool = False,
|
|
703
|
+
computed: bool = False,
|
|
704
|
+
alias_generator: Callable[[str], str] | None = None,
|
|
705
|
+
) -> dict[str, object]:
|
|
706
|
+
"""Serialize a dataclass instance to a JSON-compatible dictionary.
|
|
707
|
+
|
|
708
|
+
Parameters
|
|
709
|
+
----------
|
|
710
|
+
obj:
|
|
711
|
+
Dataclass instance to serialize.
|
|
712
|
+
|
|
713
|
+
Returns
|
|
714
|
+
-------
|
|
715
|
+
dict[str, object]
|
|
716
|
+
Serialized representation with nested dataclasses expanded.
|
|
717
|
+
"""
|
|
718
|
+
if not dataclasses.is_dataclass(obj) or isinstance(obj, type):
|
|
719
|
+
raise TypeError("dump() requires a dataclass instance")
|
|
720
|
+
|
|
721
|
+
result: dict[str, object] = {}
|
|
722
|
+
for field in dataclasses.fields(obj):
|
|
723
|
+
field_meta = dict(field.metadata) if field.metadata is not None else {}
|
|
724
|
+
key = field.name
|
|
725
|
+
if by_alias:
|
|
726
|
+
alias = field_meta.get("alias")
|
|
727
|
+
if alias is None and alias_generator is not None:
|
|
728
|
+
alias = alias_generator(field.name)
|
|
729
|
+
if alias:
|
|
730
|
+
key = alias
|
|
731
|
+
value = getattr(obj, field.name)
|
|
732
|
+
serialized = _serialize(
|
|
733
|
+
value,
|
|
734
|
+
by_alias=by_alias,
|
|
735
|
+
exclude_none=exclude_none,
|
|
736
|
+
alias_generator=alias_generator,
|
|
737
|
+
)
|
|
738
|
+
if serialized is MISSING_SENTINEL:
|
|
739
|
+
continue
|
|
740
|
+
result[key] = serialized
|
|
741
|
+
|
|
742
|
+
if computed and hasattr(obj.__class__, "__computed__"):
|
|
743
|
+
for name in getattr(obj.__class__, "__computed__", ()): # type: ignore[attr-defined]
|
|
744
|
+
value = getattr(obj, name)
|
|
745
|
+
serialized = _serialize(
|
|
746
|
+
value,
|
|
747
|
+
by_alias=by_alias,
|
|
748
|
+
exclude_none=exclude_none,
|
|
749
|
+
alias_generator=alias_generator,
|
|
750
|
+
)
|
|
751
|
+
if serialized is MISSING_SENTINEL:
|
|
752
|
+
continue
|
|
753
|
+
key = name
|
|
754
|
+
if by_alias and alias_generator is not None:
|
|
755
|
+
key = alias_generator(name)
|
|
756
|
+
result[key] = serialized
|
|
757
|
+
|
|
758
|
+
return result
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def clone[T](obj: T, **updates: object) -> T:
|
|
762
|
+
"""Clone a dataclass instance and re-run model-level validation hooks."""
|
|
763
|
+
if not dataclasses.is_dataclass(obj) or isinstance(obj, type):
|
|
764
|
+
raise TypeError("clone() requires a dataclass instance")
|
|
765
|
+
field_names = {field.name for field in dataclasses.fields(obj)}
|
|
766
|
+
extras: dict[str, object] = {}
|
|
767
|
+
extras_attr = getattr(obj, "__extras__", None)
|
|
768
|
+
if hasattr(obj, "__dict__"):
|
|
769
|
+
extras = {
|
|
770
|
+
key: value for key, value in obj.__dict__.items() if key not in field_names
|
|
771
|
+
}
|
|
772
|
+
elif isinstance(extras_attr, Mapping):
|
|
773
|
+
extras = dict(extras_attr)
|
|
774
|
+
|
|
775
|
+
cloned = dataclasses.replace(obj, **updates)
|
|
776
|
+
|
|
777
|
+
if extras:
|
|
778
|
+
if hasattr(cloned, "__dict__"):
|
|
779
|
+
for key, value in extras.items():
|
|
780
|
+
object.__setattr__(cloned, key, value)
|
|
781
|
+
else:
|
|
782
|
+
_set_extras(cloned, extras)
|
|
783
|
+
|
|
784
|
+
validator = getattr(cloned, "__validate__", None)
|
|
785
|
+
if callable(validator):
|
|
786
|
+
validator()
|
|
787
|
+
post_validator = getattr(cloned, "__post_validate__", None)
|
|
788
|
+
if callable(post_validator):
|
|
789
|
+
post_validator()
|
|
790
|
+
return cloned
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _schema_constraints(meta: Mapping[str, object]) -> dict[str, object]:
|
|
794
|
+
schema_meta: dict[str, object] = {}
|
|
795
|
+
mapping = {
|
|
796
|
+
"ge": "minimum",
|
|
797
|
+
"minimum": "minimum",
|
|
798
|
+
"gt": "exclusiveMinimum",
|
|
799
|
+
"exclusiveMinimum": "exclusiveMinimum",
|
|
800
|
+
"le": "maximum",
|
|
801
|
+
"maximum": "maximum",
|
|
802
|
+
"lt": "exclusiveMaximum",
|
|
803
|
+
"exclusiveMaximum": "exclusiveMaximum",
|
|
804
|
+
"min_length": "minLength",
|
|
805
|
+
"minLength": "minLength",
|
|
806
|
+
"max_length": "maxLength",
|
|
807
|
+
"maxLength": "maxLength",
|
|
808
|
+
"regex": "pattern",
|
|
809
|
+
"pattern": "pattern",
|
|
810
|
+
}
|
|
811
|
+
for key, target in mapping.items():
|
|
812
|
+
if key in meta and target not in schema_meta:
|
|
813
|
+
schema_meta[target] = meta[key]
|
|
814
|
+
members = meta.get("enum") or meta.get("in")
|
|
815
|
+
if isinstance(members, Iterable) and not isinstance(members, (str, bytes)):
|
|
816
|
+
schema_meta.setdefault("enum", _ordered_values(members))
|
|
817
|
+
not_members = meta.get("not_in")
|
|
818
|
+
if (
|
|
819
|
+
isinstance(not_members, Iterable)
|
|
820
|
+
and not isinstance(not_members, (str, bytes))
|
|
821
|
+
and "not" not in schema_meta
|
|
822
|
+
):
|
|
823
|
+
schema_meta["not"] = {"enum": _ordered_values(not_members)}
|
|
824
|
+
return schema_meta
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _schema_for_type(
|
|
828
|
+
typ: object,
|
|
829
|
+
meta: Mapping[str, object] | None,
|
|
830
|
+
alias_generator: Callable[[str], str] | None,
|
|
831
|
+
) -> dict[str, object]:
|
|
832
|
+
base_type, merged_meta = _merge_annotated_meta(typ, meta)
|
|
833
|
+
origin = get_origin(base_type)
|
|
834
|
+
|
|
835
|
+
if base_type in {object, _AnyType}:
|
|
836
|
+
schema_data: dict[str, object] = {}
|
|
837
|
+
elif dataclasses.is_dataclass(base_type):
|
|
838
|
+
dataclass_type = base_type if isinstance(base_type, type) else type(base_type)
|
|
839
|
+
schema_data = schema(dataclass_type, alias_generator=alias_generator)
|
|
840
|
+
elif base_type is type(None):
|
|
841
|
+
schema_data = {"type": "null"}
|
|
842
|
+
elif isinstance(base_type, type) and issubclass(base_type, Enum):
|
|
843
|
+
enum_values = [member.value for member in base_type]
|
|
844
|
+
schema_data = {"enum": enum_values}
|
|
845
|
+
if enum_values:
|
|
846
|
+
if all(isinstance(value, str) for value in enum_values):
|
|
847
|
+
schema_data["type"] = "string"
|
|
848
|
+
elif all(isinstance(value, bool) for value in enum_values):
|
|
849
|
+
schema_data["type"] = "boolean"
|
|
850
|
+
elif all(
|
|
851
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
852
|
+
for value in enum_values
|
|
853
|
+
):
|
|
854
|
+
schema_data["type"] = "integer"
|
|
855
|
+
elif all(isinstance(value, (float, Decimal)) for value in enum_values):
|
|
856
|
+
schema_data["type"] = "number"
|
|
857
|
+
elif base_type is bool:
|
|
858
|
+
schema_data = {"type": "boolean"}
|
|
859
|
+
elif base_type is int:
|
|
860
|
+
schema_data = {"type": "integer"}
|
|
861
|
+
elif base_type in {float, Decimal}:
|
|
862
|
+
schema_data = {"type": "number"}
|
|
863
|
+
elif base_type is str:
|
|
864
|
+
schema_data = {"type": "string"}
|
|
865
|
+
elif base_type is datetime:
|
|
866
|
+
schema_data = {"type": "string", "format": "date-time"}
|
|
867
|
+
elif base_type is date:
|
|
868
|
+
schema_data = {"type": "string", "format": "date"}
|
|
869
|
+
elif base_type is time:
|
|
870
|
+
schema_data = {"type": "string", "format": "time"}
|
|
871
|
+
elif base_type is UUID:
|
|
872
|
+
schema_data = {"type": "string", "format": "uuid"}
|
|
873
|
+
elif base_type is Path:
|
|
874
|
+
schema_data = {"type": "string"}
|
|
875
|
+
elif origin is Literal:
|
|
876
|
+
literal_values = list(get_args(base_type))
|
|
877
|
+
schema_data = {"enum": literal_values}
|
|
878
|
+
if literal_values:
|
|
879
|
+
if all(isinstance(value, bool) for value in literal_values):
|
|
880
|
+
schema_data["type"] = "boolean"
|
|
881
|
+
elif all(isinstance(value, str) for value in literal_values):
|
|
882
|
+
schema_data["type"] = "string"
|
|
883
|
+
elif all(
|
|
884
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
885
|
+
for value in literal_values
|
|
886
|
+
):
|
|
887
|
+
schema_data["type"] = "integer"
|
|
888
|
+
elif all(isinstance(value, (float, Decimal)) for value in literal_values):
|
|
889
|
+
schema_data["type"] = "number"
|
|
890
|
+
elif origin in {list, Sequence}:
|
|
891
|
+
item_type = get_args(base_type)[0] if get_args(base_type) else object
|
|
892
|
+
schema_data = {
|
|
893
|
+
"type": "array",
|
|
894
|
+
"items": _schema_for_type(item_type, None, alias_generator),
|
|
895
|
+
}
|
|
896
|
+
elif origin is set:
|
|
897
|
+
item_type = get_args(base_type)[0] if get_args(base_type) else object
|
|
898
|
+
schema_data = {
|
|
899
|
+
"type": "array",
|
|
900
|
+
"items": _schema_for_type(item_type, None, alias_generator),
|
|
901
|
+
"uniqueItems": True,
|
|
902
|
+
}
|
|
903
|
+
elif origin is tuple:
|
|
904
|
+
args = get_args(base_type)
|
|
905
|
+
if args and args[-1] is Ellipsis:
|
|
906
|
+
schema_data = {
|
|
907
|
+
"type": "array",
|
|
908
|
+
"items": _schema_for_type(args[0], None, alias_generator),
|
|
909
|
+
}
|
|
910
|
+
else:
|
|
911
|
+
schema_data = {
|
|
912
|
+
"type": "array",
|
|
913
|
+
"prefixItems": [
|
|
914
|
+
_schema_for_type(arg, None, alias_generator) for arg in args
|
|
915
|
+
],
|
|
916
|
+
"minItems": len(args),
|
|
917
|
+
"maxItems": len(args),
|
|
918
|
+
}
|
|
919
|
+
elif origin in {dict, Mapping}:
|
|
920
|
+
args = get_args(base_type)
|
|
921
|
+
value_type = args[1] if len(args) == 2 else object
|
|
922
|
+
schema_data = {
|
|
923
|
+
"type": "object",
|
|
924
|
+
"additionalProperties": _schema_for_type(value_type, None, alias_generator),
|
|
925
|
+
}
|
|
926
|
+
elif origin is Union:
|
|
927
|
+
subschemas = []
|
|
928
|
+
includes_null = False
|
|
929
|
+
base_schema_ref: Mapping[str, object] | None = None
|
|
930
|
+
for arg in get_args(base_type):
|
|
931
|
+
if arg is type(None):
|
|
932
|
+
includes_null = True
|
|
933
|
+
continue
|
|
934
|
+
subschema = _schema_for_type(arg, None, alias_generator)
|
|
935
|
+
subschemas.append(subschema)
|
|
936
|
+
if (
|
|
937
|
+
base_schema_ref is None
|
|
938
|
+
and isinstance(subschema, Mapping)
|
|
939
|
+
and subschema.get("type") == "object"
|
|
940
|
+
):
|
|
941
|
+
base_schema_ref = subschema
|
|
942
|
+
any_of = list(subschemas)
|
|
943
|
+
if includes_null:
|
|
944
|
+
any_of.append({"type": "null"})
|
|
945
|
+
if base_schema_ref is not None and len(subschemas) == 1:
|
|
946
|
+
schema_data = dict(base_schema_ref)
|
|
947
|
+
else:
|
|
948
|
+
schema_data = {}
|
|
949
|
+
schema_data["anyOf"] = any_of
|
|
950
|
+
non_null_types = [
|
|
951
|
+
subschema.get("type")
|
|
952
|
+
for subschema in subschemas
|
|
953
|
+
if isinstance(subschema.get("type"), str)
|
|
954
|
+
and subschema.get("type") != "null"
|
|
955
|
+
]
|
|
956
|
+
if non_null_types and len(set(non_null_types)) == 1:
|
|
957
|
+
schema_data["type"] = non_null_types[0]
|
|
958
|
+
if len(subschemas) == 1 and base_schema_ref is None:
|
|
959
|
+
title = subschemas[0].get("title")
|
|
960
|
+
if isinstance(title, str): # pragma: no cover - not triggered in tests
|
|
961
|
+
schema_data.setdefault("title", title)
|
|
962
|
+
required = subschemas[0].get("required")
|
|
963
|
+
if isinstance(required, (list, tuple)): # pragma: no cover - defensive
|
|
964
|
+
schema_data.setdefault("required", list(required))
|
|
965
|
+
else:
|
|
966
|
+
schema_data = {}
|
|
967
|
+
|
|
968
|
+
schema_data.update(_schema_constraints(merged_meta))
|
|
969
|
+
return schema_data
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def schema(
|
|
973
|
+
cls: type[object],
|
|
974
|
+
*,
|
|
975
|
+
alias_generator: Callable[[str], str] | None = None,
|
|
976
|
+
extra: Literal["ignore", "forbid", "allow"] = "ignore",
|
|
977
|
+
) -> dict[str, object]:
|
|
978
|
+
"""Produce a minimal JSON Schema description for a dataclass."""
|
|
979
|
+
if not dataclasses.is_dataclass(cls) or not isinstance(cls, type):
|
|
980
|
+
raise TypeError("schema() requires a dataclass type")
|
|
981
|
+
if extra not in {"ignore", "forbid", "allow"}:
|
|
982
|
+
raise ValueError("extra must be one of 'ignore', 'forbid', or 'allow'")
|
|
983
|
+
|
|
984
|
+
properties: dict[str, object] = {}
|
|
985
|
+
required: list[str] = []
|
|
986
|
+
type_hints = get_type_hints(cls, include_extras=True)
|
|
987
|
+
|
|
988
|
+
for field in dataclasses.fields(cls):
|
|
989
|
+
if not field.init:
|
|
990
|
+
continue
|
|
991
|
+
field_meta = dict(field.metadata) if field.metadata is not None else {}
|
|
992
|
+
alias = field_meta.get("alias")
|
|
993
|
+
if alias_generator is not None and not alias:
|
|
994
|
+
alias = alias_generator(field.name)
|
|
995
|
+
property_name = alias or field.name
|
|
996
|
+
field_type = type_hints.get(field.name, field.type)
|
|
997
|
+
properties[property_name] = _schema_for_type(
|
|
998
|
+
field_type, field_meta, alias_generator
|
|
999
|
+
)
|
|
1000
|
+
if field.default is MISSING and field.default_factory is MISSING:
|
|
1001
|
+
required.append(property_name)
|
|
1002
|
+
|
|
1003
|
+
schema_dict = {
|
|
1004
|
+
"title": cls.__name__,
|
|
1005
|
+
"type": "object",
|
|
1006
|
+
"properties": properties,
|
|
1007
|
+
"additionalProperties": extra != "forbid",
|
|
1008
|
+
}
|
|
1009
|
+
if required:
|
|
1010
|
+
schema_dict["required"] = required
|
|
1011
|
+
if not required:
|
|
1012
|
+
schema_dict.pop("required", None)
|
|
1013
|
+
return schema_dict
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
__all__ = ["parse", "dump", "clone", "schema"]
|