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.

@@ -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"]