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.
@@ -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