mcp4api 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.
- mcp4api/__init__.py +1 -0
- mcp4api/__main__.py +5 -0
- mcp4api/cassettes.py +96 -0
- mcp4api/cli.py +665 -0
- mcp4api/curate/__init__.py +3 -0
- mcp4api/curate/agent.py +850 -0
- mcp4api/curate/criteria.md +21 -0
- mcp4api/curate/draft.py +606 -0
- mcp4api/curate/models.py +614 -0
- mcp4api/curate/rules.py +1059 -0
- mcp4api/emit/__init__.py +205 -0
- mcp4api/emit/catalog.py +717 -0
- mcp4api/emit/render.py +917 -0
- mcp4api/emit/templates/README.md.j2 +58 -0
- mcp4api/emit/templates/client.py.j2 +305 -0
- mcp4api/emit/templates/conftest.py.j2 +20 -0
- mcp4api/emit/templates/file.py.j2 +4 -0
- mcp4api/emit/templates/header.py.j2 +2 -0
- mcp4api/emit/templates/main.py.j2 +99 -0
- mcp4api/emit/templates/pyproject.toml.j2 +24 -0
- mcp4api/emit/templates/test_tools.py.j2 +40 -0
- mcp4api/errors.py +35 -0
- mcp4api/ingest/__init__.py +3 -0
- mcp4api/ingest/build.py +1064 -0
- mcp4api/ingest/convert.py +569 -0
- mcp4api/ingest/load.py +603 -0
- mcp4api/model.py +324 -0
- mcp4api/py.typed +0 -0
- mcp4api/report.py +330 -0
- mcp4api/suite.py +1384 -0
- mcp4api/verify.py +1114 -0
- mcp4api-0.1.0.dist-info/METADATA +157 -0
- mcp4api-0.1.0.dist-info/RECORD +36 -0
- mcp4api-0.1.0.dist-info/WHEEL +4 -0
- mcp4api-0.1.0.dist-info/entry_points.txt +2 -0
- mcp4api-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp4api/emit/catalog.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import keyword
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from mcp4api.model import JsonObject, JsonSchema, JsonValue
|
|
10
|
+
|
|
11
|
+
_LINE = 100
|
|
12
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_DOC_KEYS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"$comment",
|
|
18
|
+
"$id",
|
|
19
|
+
"$schema",
|
|
20
|
+
"contentEncoding",
|
|
21
|
+
"contentMediaType",
|
|
22
|
+
"default",
|
|
23
|
+
"deprecated",
|
|
24
|
+
"description",
|
|
25
|
+
"discriminator",
|
|
26
|
+
"example",
|
|
27
|
+
"examples",
|
|
28
|
+
"externalDocs",
|
|
29
|
+
"format",
|
|
30
|
+
"readOnly",
|
|
31
|
+
"title",
|
|
32
|
+
"writeOnly",
|
|
33
|
+
"xml",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_CONSTRAINT_KEYS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"exclusiveMaximum",
|
|
41
|
+
"exclusiveMinimum",
|
|
42
|
+
"maxItems",
|
|
43
|
+
"maxLength",
|
|
44
|
+
"maximum",
|
|
45
|
+
"minItems",
|
|
46
|
+
"minLength",
|
|
47
|
+
"minimum",
|
|
48
|
+
"pattern",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_CORE_KEYS = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"$defs",
|
|
56
|
+
"$ref",
|
|
57
|
+
"additionalProperties",
|
|
58
|
+
"allOf",
|
|
59
|
+
"anyOf",
|
|
60
|
+
"const",
|
|
61
|
+
"enum",
|
|
62
|
+
"items",
|
|
63
|
+
"nullable",
|
|
64
|
+
"properties",
|
|
65
|
+
"required",
|
|
66
|
+
"type",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_RESERVED_MODELS = frozenset({"Annotated", "BaseModel", "ConfigDict", "Field", "Literal"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _SchemaProblem(Exception):
|
|
75
|
+
def __init__(self, detail: str) -> None:
|
|
76
|
+
super().__init__(detail)
|
|
77
|
+
self.detail = detail
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class _Type:
|
|
82
|
+
annotation: str
|
|
83
|
+
nullable: bool
|
|
84
|
+
constraints: str
|
|
85
|
+
model: str | None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Catalog:
|
|
89
|
+
def __init__(self, spec_defs: dict[str, JsonObject]) -> None:
|
|
90
|
+
self._spec_defs = spec_defs
|
|
91
|
+
self._taken = set(_RESERVED_MODELS)
|
|
92
|
+
self._order: list[str] = []
|
|
93
|
+
self._fields: dict[str, tuple[list[str], bool]] = {}
|
|
94
|
+
self._by_body: dict[str, str] = {}
|
|
95
|
+
self._refs: dict[str, _Type] = {}
|
|
96
|
+
self._building: set[str] = set()
|
|
97
|
+
self._finished: set[str] = set()
|
|
98
|
+
self._cyclic: set[str] = set()
|
|
99
|
+
self._ref_stack: set[str] = set()
|
|
100
|
+
self.needs_annotated = False
|
|
101
|
+
self.needs_literal = False
|
|
102
|
+
self.needs_field = False
|
|
103
|
+
self.needs_config = False
|
|
104
|
+
|
|
105
|
+
def _reserve(self, base: str) -> str:
|
|
106
|
+
name = base or "Model"
|
|
107
|
+
if name[0].isdigit():
|
|
108
|
+
name = f"Model{name}"
|
|
109
|
+
if keyword.iskeyword(name):
|
|
110
|
+
name = f"{name}_"
|
|
111
|
+
candidate = name
|
|
112
|
+
index = 2
|
|
113
|
+
while candidate in self._taken:
|
|
114
|
+
candidate = f"{name}{index}"
|
|
115
|
+
index += 1
|
|
116
|
+
self._taken.add(candidate)
|
|
117
|
+
return candidate
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def classes(self) -> set[str]:
|
|
121
|
+
return set(self._finished)
|
|
122
|
+
|
|
123
|
+
def compile_output(self, schema: JsonSchema, tool_name: str) -> _Type:
|
|
124
|
+
hint = f"{_pascal(tool_name)}Result"
|
|
125
|
+
if isinstance(schema, dict) and _named_object(schema):
|
|
126
|
+
return self._compile(schema, hint=hint, model_name=self._reserve(hint), defs={})
|
|
127
|
+
return self._compile(schema, hint=hint, model_name=None, defs={})
|
|
128
|
+
|
|
129
|
+
def compile_input(self, schema: JsonSchema, tool_name: str, param: str) -> _Type:
|
|
130
|
+
hint = f"{_pascal(tool_name)}{_pascal(param)}"
|
|
131
|
+
return self._compile(schema, hint=hint, model_name=None, defs={})
|
|
132
|
+
|
|
133
|
+
def source(self) -> str:
|
|
134
|
+
if not self._order:
|
|
135
|
+
return ""
|
|
136
|
+
lines = ["from __future__ import annotations", ""]
|
|
137
|
+
typing_names: list[str] = []
|
|
138
|
+
if self.needs_annotated:
|
|
139
|
+
typing_names.append("Annotated")
|
|
140
|
+
if self.needs_literal:
|
|
141
|
+
typing_names.append("Literal")
|
|
142
|
+
if typing_names:
|
|
143
|
+
lines.extend([f"from typing import {', '.join(typing_names)}", ""])
|
|
144
|
+
pydantic = ["BaseModel"]
|
|
145
|
+
if self.needs_config:
|
|
146
|
+
pydantic.append("ConfigDict")
|
|
147
|
+
if self.needs_field:
|
|
148
|
+
pydantic.append("Field")
|
|
149
|
+
lines.extend([f"from pydantic import {', '.join(pydantic)}", "", ""])
|
|
150
|
+
for name in self._order:
|
|
151
|
+
fields, forbid = self._fields[name]
|
|
152
|
+
lines.extend(_class_lines(name, fields, forbid))
|
|
153
|
+
lines.extend(["", ""])
|
|
154
|
+
while lines and lines[-1] == "":
|
|
155
|
+
lines.pop()
|
|
156
|
+
if self._cyclic:
|
|
157
|
+
lines.extend(["", ""])
|
|
158
|
+
for name in self._order:
|
|
159
|
+
if name in self._cyclic:
|
|
160
|
+
lines.append(f"{name}.model_rebuild()")
|
|
161
|
+
return "\n".join(lines)
|
|
162
|
+
|
|
163
|
+
def _compile(
|
|
164
|
+
self,
|
|
165
|
+
schema: JsonSchema,
|
|
166
|
+
*,
|
|
167
|
+
hint: str,
|
|
168
|
+
model_name: str | None,
|
|
169
|
+
defs: dict[str, JsonObject],
|
|
170
|
+
) -> _Type:
|
|
171
|
+
if isinstance(schema, bool) or not isinstance(schema, dict):
|
|
172
|
+
raise _SchemaProblem("boolean schema")
|
|
173
|
+
_reject(schema)
|
|
174
|
+
local = _child_defs(defs, schema)
|
|
175
|
+
peeled, nullable = _peel_null(schema)
|
|
176
|
+
if peeled is not schema:
|
|
177
|
+
return _nullify(
|
|
178
|
+
self._compile(peeled, hint=hint, model_name=model_name, defs=local), nullable
|
|
179
|
+
)
|
|
180
|
+
if "$ref" in schema:
|
|
181
|
+
if _ref_blocked(schema):
|
|
182
|
+
raise _SchemaProblem("ref is combined with other keywords")
|
|
183
|
+
ref = schema.get("$ref")
|
|
184
|
+
if not isinstance(ref, str):
|
|
185
|
+
raise _SchemaProblem("ref is not a string")
|
|
186
|
+
inner = self._compile_ref(ref, local, hint)
|
|
187
|
+
return _nullify(_with_constraints(inner, _constraints(schema)), nullable)
|
|
188
|
+
if "allOf" in schema:
|
|
189
|
+
if _combined(schema, "allOf"):
|
|
190
|
+
raise _SchemaProblem("allOf is combined with other keywords")
|
|
191
|
+
merged, merged_defs = self._merge_all(schema.get("allOf"), local)
|
|
192
|
+
return _nullify(
|
|
193
|
+
self._object(merged, hint=hint, model_name=model_name, defs=merged_defs),
|
|
194
|
+
nullable,
|
|
195
|
+
)
|
|
196
|
+
if "enum" in schema:
|
|
197
|
+
return _nullify(self._enum(schema), nullable)
|
|
198
|
+
if "const" in schema:
|
|
199
|
+
return _nullify(self._const(schema), nullable)
|
|
200
|
+
if _is_object(schema):
|
|
201
|
+
return _nullify(
|
|
202
|
+
self._object(schema, hint=hint, model_name=model_name, defs=local), nullable
|
|
203
|
+
)
|
|
204
|
+
if schema.get("type") == "array":
|
|
205
|
+
return _nullify(self._array(schema, hint=hint, defs=local), nullable)
|
|
206
|
+
return _nullify(self._scalar(schema), nullable)
|
|
207
|
+
|
|
208
|
+
def _compile_ref(self, ref: str, defs: dict[str, JsonObject], hint: str) -> _Type:
|
|
209
|
+
cached = self._refs.get(ref)
|
|
210
|
+
if cached is not None:
|
|
211
|
+
if cached.model is not None and cached.model in self._building:
|
|
212
|
+
self._cyclic.add(cached.model)
|
|
213
|
+
return cached
|
|
214
|
+
if ref in self._ref_stack:
|
|
215
|
+
raise _SchemaProblem(f"cyclic ref {ref}")
|
|
216
|
+
body = _lookup(ref, defs, self._spec_defs)
|
|
217
|
+
local = _child_defs(defs, body)
|
|
218
|
+
if _is_object(body) and "$ref" not in body and "allOf" not in body:
|
|
219
|
+
key = _canonical(body)
|
|
220
|
+
found = self._by_body.get(key)
|
|
221
|
+
if found is not None:
|
|
222
|
+
typ = _Type(found, False, "", found)
|
|
223
|
+
self._refs[ref] = typ
|
|
224
|
+
if found in self._building:
|
|
225
|
+
self._cyclic.add(found)
|
|
226
|
+
return typ
|
|
227
|
+
name = self._reserve(_def_name(ref))
|
|
228
|
+
self._by_body[key] = name
|
|
229
|
+
self._refs[ref] = _Type(name, False, "", name)
|
|
230
|
+
compiled = self._object(body, hint=hint, model_name=name, defs=local)
|
|
231
|
+
self._refs[ref] = compiled
|
|
232
|
+
return compiled
|
|
233
|
+
self._ref_stack.add(ref)
|
|
234
|
+
try:
|
|
235
|
+
compiled = self._compile(body, hint=_def_name(ref), model_name=None, defs=local)
|
|
236
|
+
finally:
|
|
237
|
+
self._ref_stack.discard(ref)
|
|
238
|
+
self._refs[ref] = compiled
|
|
239
|
+
return compiled
|
|
240
|
+
|
|
241
|
+
def _object(
|
|
242
|
+
self,
|
|
243
|
+
schema: JsonObject,
|
|
244
|
+
*,
|
|
245
|
+
hint: str,
|
|
246
|
+
model_name: str | None,
|
|
247
|
+
defs: dict[str, JsonObject],
|
|
248
|
+
) -> _Type:
|
|
249
|
+
if model_name is None:
|
|
250
|
+
key = _canonical(schema)
|
|
251
|
+
found = self._by_body.get(key)
|
|
252
|
+
if found is not None:
|
|
253
|
+
if found in self._building:
|
|
254
|
+
self._cyclic.add(found)
|
|
255
|
+
return _Type(found, False, "", found)
|
|
256
|
+
name = self._reserve(_pascal(hint))
|
|
257
|
+
self._by_body[key] = name
|
|
258
|
+
else:
|
|
259
|
+
name = model_name
|
|
260
|
+
if name in self._finished:
|
|
261
|
+
return _Type(name, False, "", name)
|
|
262
|
+
if name in self._building:
|
|
263
|
+
self._cyclic.add(name)
|
|
264
|
+
return _Type(name, False, "", name)
|
|
265
|
+
self._building.add(name)
|
|
266
|
+
try:
|
|
267
|
+
fields, forbid = self._properties(schema, owner=name, defs=defs)
|
|
268
|
+
finally:
|
|
269
|
+
self._building.discard(name)
|
|
270
|
+
self._finished.add(name)
|
|
271
|
+
self._order.append(name)
|
|
272
|
+
self._fields[name] = (fields, forbid)
|
|
273
|
+
if forbid:
|
|
274
|
+
self.needs_config = True
|
|
275
|
+
return _Type(name, False, "", name)
|
|
276
|
+
|
|
277
|
+
def _properties(
|
|
278
|
+
self, schema: JsonObject, *, owner: str, defs: dict[str, JsonObject]
|
|
279
|
+
) -> tuple[list[str], bool]:
|
|
280
|
+
if "additionalProperties" in schema and schema.get("additionalProperties") is not False:
|
|
281
|
+
if schema.get("additionalProperties") is True:
|
|
282
|
+
raise _SchemaProblem("additionalProperties is true")
|
|
283
|
+
raise _SchemaProblem("additionalProperties schema is not supported")
|
|
284
|
+
raw_props = schema.get("properties", {})
|
|
285
|
+
if raw_props is None:
|
|
286
|
+
raw_props = {}
|
|
287
|
+
props = _as_object(raw_props, "properties")
|
|
288
|
+
required = _required(schema)
|
|
289
|
+
fields: list[str] = []
|
|
290
|
+
for name, prop in props.items():
|
|
291
|
+
prop_schema = _as_object(prop, "property schema")
|
|
292
|
+
description = prop_schema.get("description")
|
|
293
|
+
text = description if isinstance(description, str) else ""
|
|
294
|
+
typ = self._compile(
|
|
295
|
+
prop_schema, hint=f"{owner}{_pascal(name)}", model_name=None, defs=defs
|
|
296
|
+
)
|
|
297
|
+
fields.append(self._field(name, typ, required=name in required, description=text))
|
|
298
|
+
return fields, schema.get("additionalProperties") is False
|
|
299
|
+
|
|
300
|
+
def _field(self, name: str, typ: _Type, *, required: bool, description: str) -> str:
|
|
301
|
+
py_name = _ident(name)
|
|
302
|
+
alias = name if py_name != name else None
|
|
303
|
+
annotation = typ.annotation
|
|
304
|
+
if not required and not typ.nullable and annotation != "None":
|
|
305
|
+
annotation = f"{annotation} | None"
|
|
306
|
+
parts: list[str] = []
|
|
307
|
+
if description:
|
|
308
|
+
parts.append(f"description={_py(description)}")
|
|
309
|
+
if alias is not None:
|
|
310
|
+
parts.append(f"alias={_py(alias)}")
|
|
311
|
+
if typ.constraints:
|
|
312
|
+
parts.append(typ.constraints)
|
|
313
|
+
default = "" if required else " = None"
|
|
314
|
+
if not parts:
|
|
315
|
+
return f"{py_name}: {annotation}{default}"
|
|
316
|
+
self.needs_field = True
|
|
317
|
+
self.needs_annotated = True
|
|
318
|
+
rendered = f"Field({', '.join(parts)})"
|
|
319
|
+
one = f"{py_name}: Annotated[{annotation}, {rendered}]{default}"
|
|
320
|
+
if len(one) + 4 <= _LINE:
|
|
321
|
+
return one
|
|
322
|
+
return "\n".join(
|
|
323
|
+
[f"{py_name}: Annotated[", f" {annotation},", f" {rendered},", f"]{default}"]
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def _array(self, schema: JsonObject, *, hint: str, defs: dict[str, JsonObject]) -> _Type:
|
|
327
|
+
items = schema.get("items")
|
|
328
|
+
if isinstance(items, list) or not isinstance(items, dict):
|
|
329
|
+
raise _SchemaProblem("array items are not supported")
|
|
330
|
+
item = self._compile(items, hint=f"{hint}Item", model_name=None, defs=defs)
|
|
331
|
+
return _Type(f"list[{item.annotation}]", False, _constraints(schema), None)
|
|
332
|
+
|
|
333
|
+
def _enum(self, schema: JsonObject) -> _Type:
|
|
334
|
+
raw = schema.get("enum")
|
|
335
|
+
if not isinstance(raw, list) or not raw:
|
|
336
|
+
raise _SchemaProblem("enum is empty")
|
|
337
|
+
values: list[JsonValue] = []
|
|
338
|
+
nullable = False
|
|
339
|
+
for item in raw:
|
|
340
|
+
if item is None:
|
|
341
|
+
nullable = True
|
|
342
|
+
continue
|
|
343
|
+
if not _scalar(item):
|
|
344
|
+
raise _SchemaProblem("enum value is not a scalar")
|
|
345
|
+
values.append(item)
|
|
346
|
+
self.needs_literal = True
|
|
347
|
+
if not values:
|
|
348
|
+
return _Type("None", True, _constraints(schema), None)
|
|
349
|
+
annotation = "Literal[" + ", ".join(_py(item) for item in values) + "]"
|
|
350
|
+
if nullable:
|
|
351
|
+
annotation = f"{annotation} | None"
|
|
352
|
+
return _Type(annotation, nullable, _constraints(schema), None)
|
|
353
|
+
|
|
354
|
+
def _const(self, schema: JsonObject) -> _Type:
|
|
355
|
+
if "const" not in schema:
|
|
356
|
+
raise _SchemaProblem("const is missing")
|
|
357
|
+
value = schema.get("const")
|
|
358
|
+
if not _scalar(value):
|
|
359
|
+
raise _SchemaProblem("const value is not a scalar")
|
|
360
|
+
self.needs_literal = True
|
|
361
|
+
if value is None:
|
|
362
|
+
return _Type("None", True, "", None)
|
|
363
|
+
return _Type(f"Literal[{_py(value)}]", False, _constraints(schema), None)
|
|
364
|
+
|
|
365
|
+
def _scalar(self, schema: JsonObject) -> _Type:
|
|
366
|
+
for key in ("properties", "required", "additionalProperties", "items"):
|
|
367
|
+
if key in schema:
|
|
368
|
+
raise _SchemaProblem(f"keyword {key} is not supported")
|
|
369
|
+
kind = schema.get("type")
|
|
370
|
+
constraints = _constraints(schema)
|
|
371
|
+
if kind == "string":
|
|
372
|
+
return _Type("str", False, constraints, None)
|
|
373
|
+
if kind == "integer":
|
|
374
|
+
return _Type("int", False, constraints, None)
|
|
375
|
+
if kind == "number":
|
|
376
|
+
return _Type("float", False, constraints, None)
|
|
377
|
+
if kind == "boolean":
|
|
378
|
+
return _Type("bool", False, constraints, None)
|
|
379
|
+
if kind == "null":
|
|
380
|
+
return _Type("None", True, "", None)
|
|
381
|
+
raise _SchemaProblem("schema is not supported")
|
|
382
|
+
|
|
383
|
+
def _merge_all(
|
|
384
|
+
self, branches: object, defs: dict[str, JsonObject]
|
|
385
|
+
) -> tuple[JsonObject, dict[str, JsonObject]]:
|
|
386
|
+
if not isinstance(branches, list) or not branches:
|
|
387
|
+
raise _SchemaProblem("allOf is empty")
|
|
388
|
+
properties: JsonObject = {}
|
|
389
|
+
required: list[JsonValue] = []
|
|
390
|
+
seen: set[str] = set()
|
|
391
|
+
forbid = False
|
|
392
|
+
accumulated = dict(defs)
|
|
393
|
+
for branch in branches:
|
|
394
|
+
flat, accumulated = self._flatten_object(branch, accumulated, set())
|
|
395
|
+
branch_props = flat.get("properties", {})
|
|
396
|
+
if isinstance(branch_props, dict):
|
|
397
|
+
for key, value in branch_props.items():
|
|
398
|
+
if key in properties and properties[key] != value:
|
|
399
|
+
raise _SchemaProblem(f"allOf property {key} conflicts")
|
|
400
|
+
properties[key] = value
|
|
401
|
+
for name in _required(flat):
|
|
402
|
+
if name not in seen:
|
|
403
|
+
seen.add(name)
|
|
404
|
+
required.append(name)
|
|
405
|
+
if flat.get("additionalProperties") is False:
|
|
406
|
+
forbid = True
|
|
407
|
+
merged: JsonObject = {"type": "object", "properties": properties, "required": required}
|
|
408
|
+
if forbid:
|
|
409
|
+
merged["additionalProperties"] = False
|
|
410
|
+
return merged, accumulated
|
|
411
|
+
|
|
412
|
+
def _flatten_object(
|
|
413
|
+
self,
|
|
414
|
+
schema: object,
|
|
415
|
+
defs: dict[str, JsonObject],
|
|
416
|
+
seen: set[str],
|
|
417
|
+
) -> tuple[JsonObject, dict[str, JsonObject]]:
|
|
418
|
+
if not isinstance(schema, dict):
|
|
419
|
+
raise _SchemaProblem("allOf branch is not an object")
|
|
420
|
+
_reject(schema)
|
|
421
|
+
local = _child_defs(defs, schema)
|
|
422
|
+
peeled, nullable = _peel_null(schema)
|
|
423
|
+
if nullable:
|
|
424
|
+
raise _SchemaProblem("allOf branch is nullable")
|
|
425
|
+
current = peeled
|
|
426
|
+
if "$ref" in current:
|
|
427
|
+
ref = current.get("$ref")
|
|
428
|
+
if not isinstance(ref, str):
|
|
429
|
+
raise _SchemaProblem("ref is not a string")
|
|
430
|
+
if ref in seen:
|
|
431
|
+
raise _SchemaProblem("cyclic allOf")
|
|
432
|
+
return self._flatten_object(_lookup(ref, local, self._spec_defs), local, seen | {ref})
|
|
433
|
+
if "allOf" in current:
|
|
434
|
+
merged, merged_defs = self._merge_all(current.get("allOf"), local)
|
|
435
|
+
return merged, merged_defs
|
|
436
|
+
if not _is_object(current):
|
|
437
|
+
raise _SchemaProblem("allOf branch is not an object")
|
|
438
|
+
return current, local
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _class_lines(name: str, fields: list[str], forbid: bool) -> list[str]:
|
|
442
|
+
lines = [f"class {name}(BaseModel):"]
|
|
443
|
+
if forbid:
|
|
444
|
+
lines.append(' model_config = ConfigDict(extra="forbid")')
|
|
445
|
+
if not fields:
|
|
446
|
+
if not forbid:
|
|
447
|
+
lines.append(" pass")
|
|
448
|
+
return lines
|
|
449
|
+
if forbid:
|
|
450
|
+
lines.append("")
|
|
451
|
+
for field_line in fields:
|
|
452
|
+
lines.extend(f" {line}" for line in field_line.split("\n"))
|
|
453
|
+
return lines
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _named_object(schema: JsonObject) -> bool:
|
|
457
|
+
current = schema
|
|
458
|
+
for _ in range(32):
|
|
459
|
+
if "$ref" in current:
|
|
460
|
+
return False
|
|
461
|
+
peeled, _nullable = _peel_null(current)
|
|
462
|
+
if peeled is not current:
|
|
463
|
+
current = peeled
|
|
464
|
+
continue
|
|
465
|
+
if "allOf" in current:
|
|
466
|
+
return True
|
|
467
|
+
return _is_object(current)
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _is_object(schema: JsonObject) -> bool:
|
|
472
|
+
if schema.get("type") == "object":
|
|
473
|
+
return True
|
|
474
|
+
return "properties" in schema and "type" not in schema and "$ref" not in schema
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _peel_null(schema: JsonObject) -> tuple[JsonObject, bool]:
|
|
478
|
+
nullable = schema.get("nullable") is True
|
|
479
|
+
if "anyOf" in schema:
|
|
480
|
+
if _combined(schema, "anyOf"):
|
|
481
|
+
raise _SchemaProblem("anyOf is combined with other keywords")
|
|
482
|
+
options = schema.get("anyOf")
|
|
483
|
+
if not isinstance(options, list):
|
|
484
|
+
raise _SchemaProblem("anyOf is not a list")
|
|
485
|
+
branches = [item for item in options if not _is_null_schema(item)]
|
|
486
|
+
nulls = [item for item in options if _is_null_schema(item)]
|
|
487
|
+
if len(branches) != 1 or not nulls or len(branches) + len(nulls) != len(options):
|
|
488
|
+
raise _SchemaProblem("anyOf is not a null union")
|
|
489
|
+
branch = branches[0]
|
|
490
|
+
if not isinstance(branch, dict):
|
|
491
|
+
raise _SchemaProblem("anyOf branch is not an object")
|
|
492
|
+
return branch, True
|
|
493
|
+
kinds = schema.get("type")
|
|
494
|
+
if isinstance(kinds, list):
|
|
495
|
+
if not kinds or any(not isinstance(item, str) for item in kinds):
|
|
496
|
+
raise _SchemaProblem("type union is not supported")
|
|
497
|
+
rest = [item for item in kinds if item != "null"]
|
|
498
|
+
if "null" not in kinds or len(rest) != 1:
|
|
499
|
+
raise _SchemaProblem("type union is not supported")
|
|
500
|
+
rewritten: JsonObject = {}
|
|
501
|
+
for key, value in schema.items():
|
|
502
|
+
if key != "type":
|
|
503
|
+
rewritten[key] = value
|
|
504
|
+
rewritten["type"] = rest[0]
|
|
505
|
+
return rewritten, True
|
|
506
|
+
return schema, nullable
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _is_null_schema(value: object) -> bool:
|
|
510
|
+
if not isinstance(value, dict):
|
|
511
|
+
return False
|
|
512
|
+
if value.get("type") == "null":
|
|
513
|
+
return True
|
|
514
|
+
return "const" in value and value.get("const") is None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _reject(schema: JsonObject) -> None:
|
|
518
|
+
for key in schema:
|
|
519
|
+
if key not in _DOC_KEYS and key not in _CONSTRAINT_KEYS and key not in _CORE_KEYS:
|
|
520
|
+
raise _SchemaProblem(f"keyword {key} is not supported")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _combined(schema: JsonObject, key: str) -> bool:
|
|
524
|
+
allowed = _DOC_KEYS | {key, "$defs", "nullable"}
|
|
525
|
+
return any(item not in allowed for item in schema)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _ref_blocked(schema: JsonObject) -> bool:
|
|
529
|
+
allowed = _DOC_KEYS | _CONSTRAINT_KEYS | {"$ref", "$defs"}
|
|
530
|
+
return any(key not in allowed for key in schema)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _constraints(schema: JsonObject) -> str:
|
|
534
|
+
parts: list[str] = []
|
|
535
|
+
_number_bound(schema, "minimum", "ge", parts)
|
|
536
|
+
_number_bound(schema, "maximum", "le", parts)
|
|
537
|
+
_number_bound(schema, "exclusiveMinimum", "gt", parts)
|
|
538
|
+
_number_bound(schema, "exclusiveMaximum", "lt", parts)
|
|
539
|
+
_int_bound(schema, "minLength", "min_length", parts)
|
|
540
|
+
_int_bound(schema, "maxLength", "max_length", parts)
|
|
541
|
+
_int_bound(schema, "minItems", "min_items", parts)
|
|
542
|
+
_int_bound(schema, "maxItems", "max_items", parts)
|
|
543
|
+
if "pattern" in schema:
|
|
544
|
+
pattern = schema.get("pattern")
|
|
545
|
+
if not isinstance(pattern, str):
|
|
546
|
+
raise _SchemaProblem("pattern is not a string")
|
|
547
|
+
parts.append(f"pattern={_py(pattern)}")
|
|
548
|
+
return ", ".join(parts)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _number_bound(schema: JsonObject, key: str, flag: str, parts: list[str]) -> None:
|
|
552
|
+
if key not in schema:
|
|
553
|
+
return
|
|
554
|
+
value = schema.get(key)
|
|
555
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
556
|
+
raise _SchemaProblem(f"{key} is not a number")
|
|
557
|
+
parts.append(f"{flag}={_number(value)}")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _int_bound(schema: JsonObject, key: str, flag: str, parts: list[str]) -> None:
|
|
561
|
+
if key not in schema:
|
|
562
|
+
return
|
|
563
|
+
value = schema.get(key)
|
|
564
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
565
|
+
raise _SchemaProblem(f"{key} is not an integer")
|
|
566
|
+
parts.append(f"{flag}={value}")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _number(value: int | float) -> str:
|
|
570
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
571
|
+
return str(value)
|
|
572
|
+
if isinstance(value, float) and math.isfinite(value):
|
|
573
|
+
return repr(value)
|
|
574
|
+
raise _SchemaProblem("constraint is not a finite number")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _required(schema: JsonObject) -> set[str]:
|
|
578
|
+
raw = schema.get("required", [])
|
|
579
|
+
if raw is None:
|
|
580
|
+
return set()
|
|
581
|
+
if not isinstance(raw, list):
|
|
582
|
+
raise _SchemaProblem("required is not a list")
|
|
583
|
+
names: set[str] = set()
|
|
584
|
+
for item in raw:
|
|
585
|
+
if not isinstance(item, str):
|
|
586
|
+
raise _SchemaProblem("required entry is not a string")
|
|
587
|
+
names.add(item)
|
|
588
|
+
return names
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _as_object(value: object, label: str) -> JsonObject:
|
|
592
|
+
if not isinstance(value, dict):
|
|
593
|
+
raise _SchemaProblem(f"{label} is not an object")
|
|
594
|
+
result: JsonObject = {}
|
|
595
|
+
for key, item in value.items():
|
|
596
|
+
if not isinstance(key, str):
|
|
597
|
+
raise _SchemaProblem(f"{label} name is not a string")
|
|
598
|
+
result[key] = item
|
|
599
|
+
return result
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _child_defs(parent: dict[str, JsonObject], schema: JsonObject) -> dict[str, JsonObject]:
|
|
603
|
+
raw = schema.get("$defs")
|
|
604
|
+
if raw is None:
|
|
605
|
+
return parent
|
|
606
|
+
if not isinstance(raw, dict):
|
|
607
|
+
raise _SchemaProblem("$defs is not an object")
|
|
608
|
+
if not raw:
|
|
609
|
+
return parent
|
|
610
|
+
child = dict(parent)
|
|
611
|
+
for key, value in raw.items():
|
|
612
|
+
if not isinstance(key, str) or not isinstance(value, dict):
|
|
613
|
+
raise _SchemaProblem("$defs entries must be schemas")
|
|
614
|
+
child[key] = value
|
|
615
|
+
return child
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _lookup(ref: str, defs: dict[str, JsonObject], spec_defs: dict[str, JsonObject]) -> JsonObject:
|
|
619
|
+
if ref.startswith("#/$defs/"):
|
|
620
|
+
body = defs.get(_unescape(ref[len("#/$defs/") :]))
|
|
621
|
+
elif ref.startswith("#/defs/"):
|
|
622
|
+
body = spec_defs.get(_unescape(ref[len("#/defs/") :]))
|
|
623
|
+
else:
|
|
624
|
+
raise _SchemaProblem(f"unsupported ref {ref}")
|
|
625
|
+
if not isinstance(body, dict):
|
|
626
|
+
raise _SchemaProblem(f"unknown ref {ref}")
|
|
627
|
+
return body
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _unescape(token: str) -> str:
|
|
631
|
+
return token.replace("~1", "/").replace("~0", "~")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _def_name(ref: str) -> str:
|
|
635
|
+
raw = _unescape(ref.rsplit("/", 1)[-1])
|
|
636
|
+
segment = raw.rsplit("_", 1)[-1]
|
|
637
|
+
return _pascal(segment or raw)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _canonical(schema: JsonObject) -> str:
|
|
641
|
+
return json.dumps(_strip(schema), sort_keys=True, ensure_ascii=False)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _strip(value: JsonValue) -> JsonValue:
|
|
645
|
+
if isinstance(value, dict):
|
|
646
|
+
return {
|
|
647
|
+
key: _strip(item)
|
|
648
|
+
for key, item in value.items()
|
|
649
|
+
if key not in _DOC_KEYS and key != "$defs"
|
|
650
|
+
}
|
|
651
|
+
if isinstance(value, list):
|
|
652
|
+
return [_strip(item) for item in value]
|
|
653
|
+
return value
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _nullify(typ: _Type, nullable: bool) -> _Type:
|
|
657
|
+
if not nullable or typ.nullable or typ.annotation == "None":
|
|
658
|
+
return typ
|
|
659
|
+
return _Type(f"{typ.annotation} | None", True, typ.constraints, typ.model)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _with_constraints(typ: _Type, extra: str) -> _Type:
|
|
663
|
+
if not extra:
|
|
664
|
+
return typ
|
|
665
|
+
if not typ.constraints:
|
|
666
|
+
return _Type(typ.annotation, typ.nullable, extra, typ.model)
|
|
667
|
+
return _Type(typ.annotation, typ.nullable, f"{typ.constraints}, {extra}", typ.model)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _scalar(value: object) -> bool:
|
|
671
|
+
if value is None or isinstance(value, str):
|
|
672
|
+
return True
|
|
673
|
+
if isinstance(value, bool):
|
|
674
|
+
return True
|
|
675
|
+
if isinstance(value, int):
|
|
676
|
+
return True
|
|
677
|
+
return isinstance(value, float) and math.isfinite(value)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _py(value: JsonValue) -> str:
|
|
681
|
+
if value is None:
|
|
682
|
+
return "None"
|
|
683
|
+
if isinstance(value, bool):
|
|
684
|
+
return "True" if value else "False"
|
|
685
|
+
if isinstance(value, int):
|
|
686
|
+
return str(value)
|
|
687
|
+
if isinstance(value, float):
|
|
688
|
+
if not math.isfinite(value):
|
|
689
|
+
raise _SchemaProblem("number is not finite")
|
|
690
|
+
return repr(value)
|
|
691
|
+
if isinstance(value, str):
|
|
692
|
+
return json.dumps(value, ensure_ascii=False)
|
|
693
|
+
raise _SchemaProblem("value is not a scalar")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _pascal(text: str) -> str:
|
|
697
|
+
parts = re.findall(r"[0-9A-Za-z]+", text)
|
|
698
|
+
if not parts:
|
|
699
|
+
return "Model"
|
|
700
|
+
name = "".join(part[:1].upper() + part[1:] for part in parts)
|
|
701
|
+
if name[0].isdigit():
|
|
702
|
+
name = f"Model{name}"
|
|
703
|
+
if keyword.iskeyword(name):
|
|
704
|
+
name = f"{name}_"
|
|
705
|
+
return name
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _ident(name: str) -> str:
|
|
709
|
+
if _IDENT.match(name) and not keyword.iskeyword(name):
|
|
710
|
+
return name
|
|
711
|
+
cleaned = re.sub(r"[^0-9A-Za-z_]", "_", name)
|
|
712
|
+
cleaned = re.sub(r"_+", "_", cleaned).strip("_")
|
|
713
|
+
if not cleaned or cleaned[0].isdigit():
|
|
714
|
+
cleaned = f"param_{cleaned}" if cleaned else "param"
|
|
715
|
+
if keyword.iskeyword(cleaned):
|
|
716
|
+
cleaned = f"{cleaned}_"
|
|
717
|
+
return cleaned
|