qstd-openapi 0.1.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.
@@ -0,0 +1,494 @@
1
+ """Schemas from Pydantic models, dataclasses and annotated types.
2
+
3
+ Install with ``qstd-openapi[pydantic]`` (Pydantic 2) or
4
+ ``qstd-openapi[pydantic-v1]`` (Pydantic 1.10). Models written against the v1
5
+ API inside Pydantic 2 (``pydantic.v1``) are supported as well.
6
+
7
+ Pydantic 1 support is **experimental** and is expected to be removed in a
8
+ future release. Pydantic 1 produces older JSON Schema; its output is
9
+ normalized to what Pydantic 2 would produce: ``definitions`` become components, ``Optional``
10
+ fields allow ``null``, single-value ``Literal`` becomes ``const`` and enums
11
+ get a ``type`` without the boilerplate description.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import dataclasses
18
+ import datetime
19
+ import decimal
20
+ import importlib
21
+ import uuid
22
+
23
+ from collections.abc import Iterator, Mapping, Sequence
24
+ from types import MappingProxyType
25
+ from typing import Any, Callable, Optional, cast, get_args
26
+
27
+ from qstd_openapi._compat import is_class
28
+ from qstd_openapi.core.schemas import (
29
+ JsonSchema,
30
+ OverrideWithin,
31
+ SchemaContext,
32
+ SchemaMode,
33
+ SchemaRequest,
34
+ )
35
+
36
+ __all__ = ('PydanticSchemas',)
37
+
38
+
39
+ def _load() -> tuple[Optional[Any], Optional[Any]]:
40
+ """``(pydantic v2 module or None, v1 API module or None)``."""
41
+ try:
42
+ pydantic: Any = importlib.import_module('pydantic')
43
+ except ImportError as exc: # pragma: no cover - extra not installed
44
+ raise ImportError(
45
+ 'qstd_openapi.pydantic needs Pydantic: install qstd-openapi[pydantic] '
46
+ 'or qstd-openapi[pydantic-v1]',
47
+ ) from exc
48
+ if str(pydantic.VERSION).startswith('1.'):
49
+ return None, pydantic
50
+ try:
51
+ v1: Any = importlib.import_module('pydantic.v1')
52
+ except ImportError: # pragma: no cover - very old 2.x
53
+ v1 = None
54
+ return pydantic, v1
55
+
56
+
57
+ class PydanticSchemas:
58
+ """Schema provider backed by Pydantic.
59
+
60
+ Accepts models, dataclasses, any type ``TypeAdapter`` understands and a
61
+ ready ``TypeAdapter`` instance (Pydantic 2). ``by_alias`` controls whether
62
+ field aliases or attribute names are used as property names; it should
63
+ match how the application serializes data.
64
+ """
65
+
66
+ def __init__(self, *, by_alias: bool = True) -> None:
67
+ self._v2, self._v1 = _load()
68
+ self.by_alias = by_alias
69
+ self._supported: dict[Any, bool] = {}
70
+
71
+ # --- protocol -------------------------------------------------------
72
+
73
+ def supports(self, target: object) -> bool:
74
+ try:
75
+ key: Any = target
76
+ hash(key)
77
+ except TypeError:
78
+ return False
79
+ if key not in self._supported:
80
+ self._supported[key] = self._check(target)
81
+ return self._supported[key]
82
+
83
+ def generate(
84
+ self,
85
+ requests: Sequence[SchemaRequest],
86
+ context: SchemaContext,
87
+ ) -> Sequence[JsonSchema]:
88
+ results: list[Optional[JsonSchema]] = [None] * len(requests)
89
+ v2_indexes = [i for i, r in enumerate(requests) if not self._is_v1(r.target)]
90
+ if v2_indexes:
91
+ for index, schema in zip(
92
+ v2_indexes,
93
+ self._generate_v2([requests[i] for i in v2_indexes], context),
94
+ ):
95
+ results[index] = schema
96
+ for index, request in enumerate(requests):
97
+ if results[index] is None:
98
+ results[index] = self._generate_v1(
99
+ request.target,
100
+ request.mode,
101
+ context,
102
+ )
103
+ return cast('list[JsonSchema]', results)
104
+
105
+ # --- detection ------------------------------------------------------
106
+
107
+ def _is_v1(self, target: object) -> bool:
108
+ """Whether ``target`` (or a type inside ``List[...]`` etc.) uses the v1 API."""
109
+ if self._v1 is None:
110
+ return False
111
+ if self._v2 is None:
112
+ return True
113
+ if is_class(target) and issubclass(target, self._v1.BaseModel):
114
+ return True
115
+ return any(self._is_v1(arg) for arg in get_args(target))
116
+
117
+ def _is_adapter(self, target: object) -> bool:
118
+ return self._v2 is not None and isinstance(target, self._v2.TypeAdapter)
119
+
120
+ def _check(self, target: object) -> bool:
121
+ if isinstance(target, Mapping):
122
+ return False
123
+ if self._is_adapter(target):
124
+ return True
125
+ try:
126
+ if self._is_v1(target):
127
+ self._v1_schema_of(target, '#/{model}')
128
+ else:
129
+ assert self._v2 is not None
130
+ self._v2.TypeAdapter(target)
131
+ except Exception:
132
+ return False
133
+ return True
134
+
135
+ # --- Pydantic 2 -----------------------------------------------------
136
+
137
+ def _generate_v2(
138
+ self,
139
+ requests: Sequence[SchemaRequest],
140
+ context: SchemaContext,
141
+ ) -> list[JsonSchema]:
142
+ assert self._v2 is not None
143
+ adapter = self._v2.TypeAdapter
144
+ generator = _override_generator(self._v2, context.type_override)
145
+ inputs = [
146
+ (
147
+ index,
148
+ r.mode,
149
+ r.target if self._is_adapter(r.target) else adapter(r.target),
150
+ )
151
+ for index, r in enumerate(requests)
152
+ ]
153
+ keys, definitions = adapter.json_schemas(
154
+ inputs,
155
+ by_alias=self.by_alias,
156
+ ref_template=context.ref_template,
157
+ schema_generator=generator,
158
+ )
159
+ for name, schema in cast(
160
+ 'Mapping[str, JsonSchema]',
161
+ definitions.get('$defs', {}),
162
+ ).items():
163
+ context.add_component(name, schema, 'PydanticSchemas')
164
+ return [
165
+ cast('JsonSchema', keys[(index, r.mode)])
166
+ for index, r in enumerate(requests)
167
+ ]
168
+
169
+ # --- Pydantic 1 -----------------------------------------------------
170
+
171
+ def _v1_schema_of(self, target: Any, ref_template: str) -> JsonSchema:
172
+ """A private copy: Pydantic 1 caches ``Model.schema()`` and we edit the result."""
173
+ assert self._v1 is not None
174
+ if is_class(target) and issubclass(target, self._v1.BaseModel):
175
+ schema = cast(Any, target).schema(
176
+ by_alias=self.by_alias,
177
+ ref_template=ref_template,
178
+ )
179
+ else:
180
+ schema = self._v1.schema_of(
181
+ target,
182
+ by_alias=self.by_alias,
183
+ ref_template=ref_template,
184
+ )
185
+ return cast('JsonSchema', copy.deepcopy(schema))
186
+
187
+ def _generate_v1(
188
+ self,
189
+ target: Any,
190
+ mode: SchemaMode,
191
+ context: SchemaContext,
192
+ ) -> JsonSchema:
193
+ assert self._v1 is not None
194
+ schema = self._v1_schema_of(target, context.ref_template)
195
+ definitions = cast('dict[str, JsonSchema]', schema.pop('definitions', {}))
196
+ is_model = is_class(target) and issubclass(target, self._v1.BaseModel)
197
+ if is_model:
198
+ name = target.__name__
199
+ definitions[name] = schema
200
+ result: JsonSchema = {'$ref': context.ref_template.format(model=name)}
201
+ else:
202
+ schema.pop('title', None) # "ParsingModel[...]" wrapper title
203
+ result = {'$ref': schema['$ref']} if '$ref' in schema else schema
204
+
205
+ changed: set[str] = set()
206
+ for model in _v1_models(target, self._v1):
207
+ definition = definitions.get(model.__name__)
208
+ if definition is not None:
209
+ if self._v1_overrides(model, definition, mode, context):
210
+ changed.add(model.__name__)
211
+ self._v1_nullable(model, definition)
212
+ if changed:
213
+ result = _v1_mode_variants(definitions, result, changed, mode, context)
214
+ for name, definition in definitions.items():
215
+ context.add_component(
216
+ name,
217
+ _v1_normalize(definition, root=True),
218
+ 'PydanticSchemas',
219
+ )
220
+ return _v1_normalize(result, root=False)
221
+
222
+ def _v1_overrides(
223
+ self,
224
+ model: Any,
225
+ definition: JsonSchema,
226
+ mode: SchemaMode,
227
+ context: SchemaContext,
228
+ ) -> bool:
229
+ """Apply ``type_overrides`` to the fields of one class.
230
+
231
+ Covers fields of the overridden type itself, its sequences and dict
232
+ values (``Optional`` is restored by ``_v1_nullable`` afterwards).
233
+ Returns whether a mode-specific override changed the definition.
234
+ """
235
+ assert self._v1 is not None
236
+ within: OverrideWithin = (
237
+ 'models' if issubclass(model, self._v1.BaseModel) else 'dataclasses'
238
+ )
239
+ properties = cast('dict[str, JsonSchema]', definition.get('properties', {}))
240
+ mode_specific = False
241
+ for field in _v1_fields(model).values():
242
+ key = field.alias if self.by_alias else field.name
243
+ prop = properties.get(key)
244
+ override = context.type_override(field.type_, mode, within)
245
+ if prop is None or override is None:
246
+ continue
247
+ if field.shape == _V1_SINGLETON:
248
+ meta = {k: prop[k] for k in _V1_FIELD_META if k in prop}
249
+ properties[key] = {**override, **meta}
250
+ elif field.shape in _V1_SEQUENCES and 'items' in prop:
251
+ prop['items'] = override
252
+ elif field.shape in _V1_MAPPINGS and 'additionalProperties' in prop:
253
+ prop['additionalProperties'] = override
254
+ else:
255
+ continue
256
+ mode_specific = mode_specific or (
257
+ context.type_override(field.type_, _other(mode), within) != override
258
+ )
259
+ return mode_specific
260
+
261
+ def _v1_nullable(self, model: Any, definition: JsonSchema) -> None:
262
+ """Pydantic 1 drops ``null`` from ``Optional`` fields; put it back."""
263
+ properties = cast('dict[str, JsonSchema]', definition.get('properties', {}))
264
+ for field in _v1_fields(model).values():
265
+ key = field.alias if self.by_alias else field.name
266
+ prop = properties.get(key)
267
+ if prop is None or not field.allow_none:
268
+ continue
269
+ meta = {
270
+ k: prop.pop(k) for k in ('title', 'description', 'default') if k in prop
271
+ }
272
+ if not field.required and field.default is None:
273
+ meta['default'] = None
274
+ properties[key] = {'anyOf': [prop, {'type': 'null'}], **meta}
275
+
276
+
277
+ _V1_SINGLETON = 1
278
+ _V1_SEQUENCES = frozenset({2, 3, 6, 7, 8, 11})
279
+ """``ModelField.shape``: list, set, tuple-ellipsis, sequence, frozenset, deque."""
280
+ _V1_MAPPINGS = frozenset({4, 12, 13})
281
+ """``ModelField.shape``: mapping, dict, defaultdict."""
282
+ _V1_FIELD_META = ('title', 'description', 'default')
283
+
284
+
285
+ def _other(mode: SchemaMode) -> SchemaMode:
286
+ return 'serialization' if mode == 'validation' else 'validation'
287
+
288
+
289
+ def _v1_mode_variants(
290
+ definitions: dict[str, JsonSchema],
291
+ result: JsonSchema,
292
+ changed: set[str],
293
+ mode: SchemaMode,
294
+ context: SchemaContext,
295
+ ) -> JsonSchema:
296
+ """Rename definitions a mode-specific override changed, as Pydantic 2 does.
297
+
298
+ Pydantic 1 has a single schema per class; when an override applies only to
299
+ requests or only to responses the class gets ``-Input``/``-Output``, and so
300
+ does every definition referring to it.
301
+ """
302
+ refs = {name: context.ref_template.format(model=name) for name in definitions}
303
+
304
+ def refers(value: Any, names: set[str]) -> bool:
305
+ if isinstance(value, dict):
306
+ items = cast('JsonSchema', value)
307
+ ref = items.get('$ref')
308
+ if any(ref == refs[name] for name in names):
309
+ return True
310
+ return any(refers(item, names) for item in items.values())
311
+ if isinstance(value, list):
312
+ return any(refers(item, names) for item in cast('list[object]', value))
313
+ return False
314
+
315
+ grown = True
316
+ while grown:
317
+ grown = False
318
+ for name, definition in definitions.items():
319
+ if name not in changed and refers(definition, changed):
320
+ changed.add(name)
321
+ grown = True
322
+ suffix = '-Input' if mode == 'validation' else '-Output'
323
+ renamed = {
324
+ refs[name]: context.ref_template.format(model=f'{name}{suffix}')
325
+ for name in changed
326
+ }
327
+
328
+ def rewrite(value: Any) -> Any:
329
+ if isinstance(value, dict):
330
+ items = cast('JsonSchema', value)
331
+ return {
332
+ key: (
333
+ renamed.get(item, item)
334
+ if key == '$ref' and isinstance(item, str)
335
+ else rewrite(item)
336
+ )
337
+ for key, item in items.items()
338
+ }
339
+ if isinstance(value, list):
340
+ return [rewrite(item) for item in cast('list[object]', value)]
341
+ return value
342
+
343
+ for name in list(definitions):
344
+ definition = cast('JsonSchema', rewrite(definitions.pop(name)))
345
+ definitions[f'{name}{suffix}' if name in changed else name] = definition
346
+ return cast('JsonSchema', rewrite(result))
347
+
348
+
349
+ _CORE_TYPES: Mapping[str, type] = MappingProxyType(
350
+ {
351
+ 'str': str,
352
+ 'int': int,
353
+ 'float': float,
354
+ 'bool': bool,
355
+ 'bytes': bytes,
356
+ 'decimal': decimal.Decimal,
357
+ 'datetime': datetime.datetime,
358
+ 'date': datetime.date,
359
+ 'time': datetime.time,
360
+ 'timedelta': datetime.timedelta,
361
+ 'uuid': uuid.UUID,
362
+ },
363
+ )
364
+
365
+
366
+ def _scalar_target(schema: Mapping[str, Any]) -> Optional[type]:
367
+ """The scalar Python type a Pydantic core schema stands for, if known."""
368
+ core_type = schema.get('type')
369
+ return _CORE_TYPES.get(core_type) if isinstance(core_type, str) else None
370
+
371
+
372
+ TypeOverrideLookup = Callable[
373
+ [Any, SchemaMode, Optional[OverrideWithin]],
374
+ Optional[JsonSchema],
375
+ ]
376
+
377
+
378
+ def _override_generator(v2: Any, lookup: TypeOverrideLookup) -> Any:
379
+ """A ``GenerateJsonSchema`` applying the document's ``type_overrides``.
380
+
381
+ Keeps a stack of the classes being described, so an override limited to
382
+ model or dataclass fields knows whose field it is looking at.
383
+ """
384
+ base: Any = importlib.import_module(f'{v2.__name__}.json_schema').GenerateJsonSchema
385
+
386
+ class _Generator(base): # type: ignore[misc, valid-type]
387
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
388
+ parent: Any = super()
389
+ parent.__init__(*args, **kwargs)
390
+ self._within: list[OverrideWithin] = []
391
+
392
+ def _lookup(self, target: Any) -> Optional[JsonSchema]:
393
+ within = self._within[-1] if self._within else None
394
+ return lookup(target, self.mode, within)
395
+
396
+ def _class_schema(self, method: str, within: Any, schema: Any) -> Any:
397
+ # The body of the class's definition: Pydantic still registers it
398
+ # under its name, so references to it stay valid.
399
+ override = self._lookup(schema.get('cls'))
400
+ if override is not None:
401
+ return override
402
+ parent: Any = super()
403
+ if within is not None:
404
+ self._within.append(within)
405
+ try:
406
+ return getattr(parent, method)(schema)
407
+ finally:
408
+ if within is not None:
409
+ self._within.pop()
410
+
411
+ def model_schema(self, schema: Any) -> Any:
412
+ return self._class_schema('model_schema', 'models', schema)
413
+
414
+ def dataclass_schema(self, schema: Any) -> Any:
415
+ return self._class_schema('dataclass_schema', 'dataclasses', schema)
416
+
417
+ def enum_schema(self, schema: Any) -> Any:
418
+ return self._class_schema('enum_schema', None, schema)
419
+
420
+ def generate_inner(self, schema: Any) -> Any:
421
+ target = _scalar_target(schema)
422
+ if target is not None:
423
+ override = self._lookup(target)
424
+ if override is not None:
425
+ return override
426
+ parent: Any = super()
427
+ return parent.generate_inner(schema)
428
+
429
+ return _Generator
430
+
431
+
432
+ def _v1_fields(model: Any) -> Mapping[str, Any]:
433
+ inner = getattr(model, '__pydantic_model__', model)
434
+ return cast('Mapping[str, Any]', getattr(inner, '__fields__', {}))
435
+
436
+
437
+ def _v1_models(target: Any, v1: Any) -> Iterator[Any]:
438
+ """Model and dataclass classes reachable from ``target``."""
439
+ seen: set[int] = set()
440
+ stack: list[Any] = [target]
441
+ while stack:
442
+ current = stack.pop()
443
+ if id(current) in seen:
444
+ continue
445
+ seen.add(id(current))
446
+ if is_class(current) and (
447
+ issubclass(current, v1.BaseModel) or dataclasses.is_dataclass(current)
448
+ ):
449
+ yield current
450
+ for field in _v1_fields(current).values():
451
+ stack.append(field.outer_type_)
452
+ stack.append(field.type_)
453
+ stack.extend(get_args(current))
454
+
455
+
456
+ def _v1_normalize(schema: JsonSchema, *, root: bool) -> JsonSchema:
457
+ """Bring Pydantic 1 output closer to Pydantic 2 (JSON Schema 2020-12)."""
458
+ result: JsonSchema = {}
459
+ for key, value in schema.items():
460
+ if isinstance(value, dict):
461
+ result[key] = _v1_normalize(cast('JsonSchema', value), root=False)
462
+ elif isinstance(value, list):
463
+ result[key] = [
464
+ (
465
+ _v1_normalize(cast('JsonSchema', item), root=False)
466
+ if isinstance(item, dict)
467
+ else item
468
+ )
469
+ for item in cast('list[object]', value)
470
+ ]
471
+ else:
472
+ result[key] = value
473
+ enum_values = result.get('enum')
474
+ if isinstance(enum_values, list):
475
+ values = cast('list[object]', enum_values)
476
+ if root:
477
+ if result.get('description') == 'An enumeration.':
478
+ del result['description']
479
+ if (
480
+ 'type' not in result
481
+ and values
482
+ and all(isinstance(v, str) for v in values)
483
+ ):
484
+ result['type'] = 'string'
485
+ elif (
486
+ 'type' not in result
487
+ and values
488
+ and all(isinstance(v, int) for v in values)
489
+ ):
490
+ result['type'] = 'integer'
491
+ elif len(values) == 1:
492
+ result['const'] = values[0]
493
+ del result['enum']
494
+ return result