structtype 0.3.0__cp313-cp313-win_amd64.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.
@@ -0,0 +1,532 @@
1
+ import re
2
+ import textwrap
3
+ from collections.abc import Callable, Iterable
4
+ from typing import Any, Final
5
+
6
+ from ._core import NODEFAULT, _dump, _json_encode
7
+ from ._inspect import (
8
+ AnyType,
9
+ BoolType,
10
+ ByteArrayType,
11
+ BytesType,
12
+ CollectionType,
13
+ CustomType,
14
+ DataclassType,
15
+ DateTimeType,
16
+ DateType,
17
+ DecimalType,
18
+ DictType,
19
+ EnumType,
20
+ FloatType,
21
+ FrozenDictType,
22
+ FrozenSetType,
23
+ IntType,
24
+ LiteralType,
25
+ MemoryViewType,
26
+ Metadata,
27
+ NamedTupleType,
28
+ NoneType,
29
+ PydanticType,
30
+ RawType,
31
+ SetType,
32
+ StrType,
33
+ StructType,
34
+ TimeDeltaType,
35
+ TimeType,
36
+ TupleType,
37
+ Type,
38
+ TypedDictType,
39
+ UnionType,
40
+ UUIDType,
41
+ _merge_json,
42
+ _sort_literal_args,
43
+ multi_type_info,
44
+ )
45
+
46
+ _REF_TEMPLATE: Final = "#/$defs/{name}"
47
+
48
+
49
+ def json_schema(
50
+ type: Any,
51
+ *,
52
+ schema_hook: Callable[[type], dict[str, Any]] | None = None,
53
+ ref_template: str = _REF_TEMPLATE,
54
+ ) -> dict[str, Any]:
55
+ """Generate a JSON Schema for a given type.
56
+
57
+ Any schemas for (potentially) shared components are extracted and stored in
58
+ a top-level ``"$defs"`` field.
59
+
60
+ If you want to generate schemas for multiple types, or to have more control
61
+ over the generated schema you may want to use ``json_schema_components`` instead.
62
+
63
+ Parameters
64
+ ----------
65
+ type : type
66
+ The type to generate the schema for.
67
+ schema_hook : callable, optional
68
+ An optional callback to use for generating JSON schemas of custom
69
+ types. Will be called with the custom type, and should return a dict
70
+ representation of the JSON schema for that type.
71
+ ref_template : str, optional
72
+ A template to use when generating ``"$ref"`` fields. This template is
73
+ formatted with the type name as ``template.format(name=name)``. This
74
+ can be useful if you intend to store the ``components`` mapping
75
+ somewhere other than a top-level ``"$defs"`` field. For example, you
76
+ might use ``ref_template="#/components/{name}"`` if generating an
77
+ OpenAPI schema.
78
+
79
+ Returns
80
+ -------
81
+ schema : dict
82
+ The generated JSON Schema.
83
+
84
+ See Also
85
+ --------
86
+ json_schema_components
87
+ """
88
+ (out,), components = json_schema_components(
89
+ (type,),
90
+ schema_hook=schema_hook,
91
+ ref_template=ref_template,
92
+ )
93
+ if components:
94
+ out["$defs"] = components
95
+ return out
96
+
97
+
98
+ def json_schema_dump(
99
+ type: Any,
100
+ *,
101
+ schema_hook: Callable[[type], dict[str, Any]] | None = None,
102
+ ref_template: str = _REF_TEMPLATE,
103
+ ) -> bytes:
104
+ """Generate a JSON Schema for the given type, returned as JSON bytes.
105
+
106
+ This is a convenience wrapper around :func:`json_schema` that encodes the
107
+ result to JSON.
108
+
109
+ Parameters
110
+ ----------
111
+ type : type
112
+ The type to generate the schema for.
113
+ schema_hook : callable, optional
114
+ An optional callback to use for generating JSON schemas of custom
115
+ types.
116
+ ref_template : str, optional
117
+ A template to use when generating ``"$ref"`` fields.
118
+ """
119
+ return _json_encode(
120
+ json_schema(type=type, schema_hook=schema_hook, ref_template=ref_template)
121
+ )
122
+
123
+
124
+ def json_schema_components(
125
+ types: Iterable[Any],
126
+ *,
127
+ schema_hook: Callable[[type], dict[str, Any]] | None = None,
128
+ ref_template: str = _REF_TEMPLATE,
129
+ ) -> tuple[tuple[dict[str, Any], ...], dict[str, Any]]:
130
+ """Generate JSON Schemas for one or more types.
131
+
132
+ Any schemas for (potentially) shared components are extracted and returned
133
+ in a separate ``components`` dict.
134
+
135
+ Parameters
136
+ ----------
137
+ types : Iterable[type]
138
+ An iterable of one or more types to generate schemas for.
139
+ schema_hook : callable, optional
140
+ An optional callback to use for generating JSON schemas of custom
141
+ types. Will be called with the custom type, and should return a dict
142
+ representation of the JSON schema for that type.
143
+ ref_template : str, optional
144
+ A template to use when generating ``"$ref"`` fields. This template is
145
+ formatted with the type name as ``template.format(name=name)``. This
146
+ can be useful if you intend to store the ``components`` mapping
147
+ somewhere other than a top-level ``"$defs"`` field. For example, you
148
+ might use ``ref_template="#/components/{name}"`` if generating an
149
+ OpenAPI schema.
150
+
151
+ Returns
152
+ -------
153
+ schemas : tuple[dict]
154
+ A tuple of JSON Schemas, one for each type in ``types``.
155
+ components : dict
156
+ A mapping of name to schema for any shared components used by
157
+ ``schemas``.
158
+
159
+ See Also
160
+ --------
161
+ schema
162
+ """
163
+ type_infos = multi_type_info(types)
164
+
165
+ component_types = _collect_component_types(type_infos)
166
+
167
+ name_map = _build_name_map(component_types)
168
+
169
+ gen = _SchemaGenerator(name_map, schema_hook, ref_template)
170
+
171
+ schemas = tuple(gen.to_schema(t) for t in type_infos)
172
+
173
+ components = {
174
+ name_map[cls]: gen.to_schema(t, False) for cls, t in component_types.items()
175
+ }
176
+ return schemas, components
177
+
178
+
179
+ def _collect_component_types(type_infos: Iterable[Type]) -> dict[Any, Type]:
180
+ """Find all types in the type tree that are "nameable" and worthy of being
181
+ extracted out into a shared top-level components mapping.
182
+
183
+ Currently this looks for Struct, Dataclass, NamedTuple, TypedDict, and Enum
184
+ types.
185
+ """
186
+ components = {}
187
+
188
+ def collect(t):
189
+ if isinstance(
190
+ t, (StructType, TypedDictType, DataclassType, NamedTupleType, PydanticType)
191
+ ):
192
+ if t.cls not in components:
193
+ components[t.cls] = t
194
+ for f in t.fields:
195
+ collect(f.type)
196
+ elif isinstance(t, EnumType):
197
+ components[t.cls] = t
198
+ elif isinstance(t, Metadata):
199
+ collect(t.type)
200
+ elif isinstance(t, CollectionType):
201
+ collect(t.item_type)
202
+ elif isinstance(t, TupleType):
203
+ for st in t.item_types:
204
+ collect(st)
205
+ elif isinstance(t, (DictType, FrozenDictType)):
206
+ collect(t.key_type)
207
+ collect(t.value_type)
208
+ elif isinstance(t, UnionType):
209
+ for st in t.types:
210
+ collect(st)
211
+
212
+ for t in type_infos:
213
+ collect(t)
214
+
215
+ return components
216
+
217
+
218
+ def _type_repr(obj):
219
+ return obj.__name__ if isinstance(obj, type) else repr(obj)
220
+
221
+
222
+ def _get_class_name(cls: Any) -> str:
223
+ if hasattr(cls, "__origin__"):
224
+ name = cls.__origin__.__name__
225
+ args = ", ".join(_type_repr(a) for a in cls.__args__)
226
+ return f"{name}[{args}]"
227
+ return cls.__name__
228
+
229
+
230
+ def _get_doc(t: Type) -> str:
231
+ assert hasattr(t, "cls")
232
+ cls = getattr(t.cls, "__origin__", t.cls)
233
+ doc = getattr(cls, "__doc__", "")
234
+ if not doc:
235
+ return ""
236
+ doc = textwrap.dedent(doc).strip("\r\n")
237
+ if isinstance(t, EnumType):
238
+ if doc == "An enumeration.":
239
+ return ""
240
+ elif isinstance(t, (NamedTupleType, DataclassType, PydanticType)):
241
+ if doc.startswith(f"{cls.__name__}(") and doc.endswith(")"):
242
+ return ""
243
+ return doc
244
+
245
+
246
+ def _build_name_map(component_types: dict[Any, Type]) -> dict[Any, str]:
247
+ """A mapping from nameable subcomponents to a generated name.
248
+
249
+ The generated name is usually a normalized version of the class name. In
250
+ the case of conflicts, the name will be expanded to also include the full
251
+ import path.
252
+ """
253
+
254
+ def normalize(name):
255
+ return re.sub(r"[^a-zA-Z0-9.\-_]", "_", name)
256
+
257
+ def fullname(cls):
258
+ return normalize(f"{cls.__module__}.{cls.__qualname__}")
259
+
260
+ conflicts = set()
261
+ names: dict[str, Any] = {}
262
+
263
+ for cls in component_types:
264
+ name = normalize(_get_class_name(cls))
265
+ if name in names:
266
+ old = names.pop(name)
267
+ conflicts.add(name)
268
+ names[fullname(old)] = old
269
+ if name in conflicts:
270
+ names[fullname(cls)] = cls
271
+ else:
272
+ names[name] = cls
273
+ return {v: k for k, v in names.items()}
274
+
275
+
276
+ class _SchemaGenerator:
277
+ def __init__(
278
+ self,
279
+ name_map: dict[Any, str],
280
+ schema_hook: Callable[[type], dict[str, Any]] | None = None,
281
+ ref_template: str = "#/$defs/{name}",
282
+ ):
283
+ self.name_map = name_map
284
+ self.schema_hook = schema_hook
285
+ self.ref_template = ref_template
286
+
287
+ def to_schema(self, t: Type, check_ref: bool = True) -> dict[str, Any]:
288
+ """Converts a Type to a json-schema."""
289
+ schema: dict[str, Any] = {}
290
+
291
+ while isinstance(t, Metadata):
292
+ schema = _merge_json(schema, t.json_schema_extra)
293
+ t = t.type
294
+
295
+ if check_ref and hasattr(t, "cls"):
296
+ if name := self.name_map.get(t.cls):
297
+ schema["$ref"] = self.ref_template.format(name=name)
298
+ return schema
299
+
300
+ if isinstance(t, (AnyType, RawType)):
301
+ pass
302
+ elif isinstance(t, NoneType):
303
+ schema["type"] = "null"
304
+ elif isinstance(t, BoolType):
305
+ schema["type"] = "boolean"
306
+ elif isinstance(t, (IntType, FloatType)):
307
+ schema["type"] = "integer" if isinstance(t, IntType) else "number"
308
+ if t.ge is not None:
309
+ schema["minimum"] = t.ge
310
+ if t.gt is not None:
311
+ schema["exclusiveMinimum"] = t.gt
312
+ if t.le is not None:
313
+ schema["maximum"] = t.le
314
+ if t.lt is not None:
315
+ schema["exclusiveMaximum"] = t.lt
316
+ if t.multiple_of is not None:
317
+ schema["multipleOf"] = t.multiple_of
318
+ elif isinstance(t, StrType):
319
+ schema["type"] = "string"
320
+ if t.max_length is not None:
321
+ schema["maxLength"] = t.max_length
322
+ if t.min_length is not None:
323
+ schema["minLength"] = t.min_length
324
+ if t.pattern is not None:
325
+ schema["pattern"] = t.pattern
326
+ elif isinstance(t, (BytesType, ByteArrayType, MemoryViewType)):
327
+ schema["type"] = "string"
328
+ schema["contentEncoding"] = "base64"
329
+ if t.max_length is not None:
330
+ schema["maxLength"] = 4 * ((t.max_length + 2) // 3)
331
+ if t.min_length is not None:
332
+ schema["minLength"] = 4 * ((t.min_length + 2) // 3)
333
+ elif isinstance(t, DateTimeType):
334
+ schema["type"] = "string"
335
+ if t.tz is True:
336
+ schema["format"] = "date-time"
337
+ elif isinstance(t, TimeType):
338
+ schema["type"] = "string"
339
+ if t.tz is True:
340
+ schema["format"] = "time"
341
+ elif t.tz is False:
342
+ schema["format"] = "partial-time"
343
+ elif isinstance(t, DateType):
344
+ schema["type"] = "string"
345
+ schema["format"] = "date"
346
+ elif isinstance(t, TimeDeltaType):
347
+ schema["type"] = "string"
348
+ schema["format"] = "duration"
349
+ elif isinstance(t, UUIDType):
350
+ schema["type"] = "string"
351
+ schema["format"] = "uuid"
352
+ elif isinstance(t, DecimalType):
353
+ schema["type"] = "string"
354
+ schema["format"] = "decimal"
355
+ elif isinstance(t, CollectionType):
356
+ schema["type"] = "array"
357
+ if not isinstance(t.item_type, AnyType):
358
+ schema["items"] = self.to_schema(t.item_type)
359
+ if t.max_length is not None:
360
+ schema["maxItems"] = t.max_length
361
+ if t.min_length is not None:
362
+ schema["minItems"] = t.min_length
363
+ if isinstance(t, (SetType, FrozenSetType)):
364
+ schema["uniqueItems"] = True
365
+ elif isinstance(t, TupleType):
366
+ schema["type"] = "array"
367
+ schema["minItems"] = schema["maxItems"] = len(t.item_types)
368
+ if t.item_types:
369
+ schema["prefixItems"] = [self.to_schema(i) for i in t.item_types]
370
+ schema["items"] = False
371
+ elif isinstance(t, (DictType, FrozenDictType)):
372
+ schema["type"] = "object"
373
+ # If there are restrictions on the keys, specify them as propertyNames
374
+ if isinstance(key_type := t.key_type, StrType):
375
+ property_names: dict[str, Any] = {}
376
+ if key_type.min_length is not None:
377
+ property_names["minLength"] = key_type.min_length
378
+ if key_type.max_length is not None:
379
+ property_names["maxLength"] = key_type.max_length
380
+ if key_type.pattern is not None:
381
+ property_names["pattern"] = key_type.pattern
382
+ if property_names:
383
+ schema["propertyNames"] = property_names
384
+ if not isinstance(t.value_type, AnyType):
385
+ schema["additionalProperties"] = self.to_schema(t.value_type)
386
+ if t.max_length is not None:
387
+ schema["maxProperties"] = t.max_length
388
+ if t.min_length is not None:
389
+ schema["minProperties"] = t.min_length
390
+ elif isinstance(t, UnionType):
391
+ structs = {}
392
+ other = []
393
+ none_member = None
394
+ tag_field = None
395
+ for subtype in t.types:
396
+ real_type = subtype
397
+ while isinstance(real_type, Metadata):
398
+ real_type = real_type.type
399
+ if isinstance(real_type, StructType) and not real_type.array_like:
400
+ tag_field = real_type.tag_field
401
+ structs[real_type.tag] = real_type
402
+ elif isinstance(real_type, NoneType):
403
+ none_member = subtype
404
+ else:
405
+ other.append(subtype)
406
+
407
+ options = [self.to_schema(a) for a in other]
408
+
409
+ if len(structs) >= 2:
410
+ mapping = {
411
+ k: self.ref_template.format(name=self.name_map[v.cls])
412
+ for k, v in structs.items()
413
+ }
414
+ struct_schema = {
415
+ "anyOf": [self.to_schema(v) for v in structs.values()],
416
+ "discriminator": {"propertyName": tag_field, "mapping": mapping},
417
+ }
418
+ if options:
419
+ options.append(struct_schema)
420
+ if none_member is not None:
421
+ options.append(self.to_schema(none_member))
422
+ schema["anyOf"] = options
423
+ elif none_member is not None:
424
+ schema["anyOf"] = [struct_schema, self.to_schema(none_member)]
425
+ else:
426
+ schema.update(struct_schema)
427
+ elif len(structs) == 1:
428
+ _, subtype = structs.popitem()
429
+ options.append(self.to_schema(subtype))
430
+ if none_member is not None:
431
+ options.append(self.to_schema(none_member))
432
+ schema["anyOf"] = options
433
+ else:
434
+ if none_member is not None:
435
+ options.append(self.to_schema(none_member))
436
+ schema["anyOf"] = options
437
+ elif isinstance(t, LiteralType):
438
+ # `t.values` may mix types (e.g. `Literal[1, None]`), which a plain
439
+ # `sorted` can't order; reuse the same type-aware sort as
440
+ # `inspect.type_info` so the two entry points agree and neither
441
+ # crashes.
442
+ schema["enum"] = list(_sort_literal_args(t.values))
443
+ elif isinstance(t, EnumType):
444
+ schema.setdefault("title", t.cls.__name__)
445
+ if doc := _get_doc(t):
446
+ schema.setdefault("description", doc)
447
+ schema["enum"] = sorted(e.value for e in t.cls)
448
+ elif isinstance(t, StructType):
449
+ schema.setdefault("title", _get_class_name(t.cls))
450
+ if doc := _get_doc(t):
451
+ schema.setdefault("description", doc)
452
+ required = []
453
+ names = []
454
+ fields = []
455
+
456
+ if t.tag_field is not None:
457
+ required.append(t.tag_field)
458
+ names.append(t.tag_field)
459
+ fields.append({"enum": [t.tag]})
460
+
461
+ for field in t.fields:
462
+ field_schema = self.to_schema(field.type)
463
+ if field.required:
464
+ required.append(field.encode_name)
465
+ elif field.default is not NODEFAULT:
466
+ field_schema["default"] = _dump(field.default, str_keys=True)
467
+ elif field.default_factory in (list, dict, set, bytearray):
468
+ field_schema["default"] = field.default_factory()
469
+ names.append(field.encode_name)
470
+ fields.append(field_schema)
471
+
472
+ if t.array_like:
473
+ n_trailing_defaults = 0
474
+ for n_trailing_defaults, f in enumerate(reversed(t.fields)):
475
+ if f.required:
476
+ break
477
+ schema["type"] = "array"
478
+ schema["prefixItems"] = fields
479
+ schema["minItems"] = len(fields) - n_trailing_defaults
480
+ if t.forbid_unknown_fields:
481
+ schema["maxItems"] = len(fields)
482
+ else:
483
+ schema["type"] = "object"
484
+ schema["properties"] = dict(zip(names, fields))
485
+ schema["required"] = required
486
+ if t.forbid_unknown_fields:
487
+ schema["additionalProperties"] = False
488
+ elif isinstance(
489
+ t, (TypedDictType, DataclassType, NamedTupleType, PydanticType)
490
+ ):
491
+ schema.setdefault("title", _get_class_name(t.cls))
492
+ if doc := _get_doc(t):
493
+ schema.setdefault("description", doc)
494
+ names = []
495
+ fields = []
496
+ required = []
497
+ for field in t.fields:
498
+ field_schema = self.to_schema(field.type)
499
+ if field.required:
500
+ required.append(field.encode_name)
501
+ elif field.default is not NODEFAULT:
502
+ field_schema["default"] = _dump(field.default, str_keys=True)
503
+ names.append(field.encode_name)
504
+ fields.append(field_schema)
505
+ if isinstance(t, NamedTupleType):
506
+ schema["type"] = "array"
507
+ schema["prefixItems"] = fields
508
+ schema["minItems"] = len(required)
509
+ schema["maxItems"] = len(fields)
510
+ else:
511
+ schema["type"] = "object"
512
+ schema["properties"] = dict(zip(names, fields))
513
+ schema["required"] = required
514
+ elif isinstance(t, CustomType):
515
+ if self.schema_hook:
516
+ try:
517
+ schema = _merge_json(self.schema_hook(t.cls), schema)
518
+ except NotImplementedError:
519
+ pass
520
+ if not schema:
521
+ raise TypeError(
522
+ "Generating JSON schema for custom types requires either:\n"
523
+ "- specifying a `schema_hook`\n"
524
+ "- annotating the type with `Field(json_schema_extra=...)`\n"
525
+ "\n"
526
+ f"type {t.cls!r} is not supported"
527
+ )
528
+ else:
529
+ # This should be unreachable
530
+ raise TypeError(f"json-schema doesn't support type {t!r}")
531
+
532
+ return schema