json-schema-utils 0.8__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.
jsutils/convert.py ADDED
@@ -0,0 +1,934 @@
1
+ from typing import Any
2
+ import re
3
+ import copy
4
+ import logging
5
+ from .utils import only
6
+ from urllib.parse import quote, unquote
7
+
8
+ type JsonPath = list[str|int]
9
+
10
+ log = logging.getLogger("convert")
11
+ # log.setLevel(logging.DEBUG)
12
+
13
+ #
14
+ # BEST EFFORT SCHEMA TO MODEL CONVERSION
15
+ #
16
+
17
+ def toconst(val):
18
+ match val:
19
+ case None:
20
+ return None # or "=null"
21
+ case bool():
22
+ return "=true" if val else "=false"
23
+ case int()|float():
24
+ return "=" + str(val)
25
+ case str():
26
+ return "_" + val
27
+ case list():
28
+ # constant list…
29
+ return {
30
+ "@": [ toconst(v) for v in val ],
31
+ "=": len(val)
32
+ }
33
+ case dict():
34
+ return { f"_{p}": toconst(v) for p, v in val.items() }
35
+ case _:
36
+ raise Exception(f"unexpected value for a constant: {val}")
37
+
38
+
39
+ def esc(s):
40
+ """Escape a string if necessary."""
41
+ if isinstance(s, str) and (len(s) == 0 or s[0] in ("$", "?", "_", "!", "=", "^")):
42
+ return "_" + s
43
+ else:
44
+ return s
45
+
46
+
47
+ def sesc(s):
48
+ if not isinstance(s, str):
49
+ return str(s)
50
+ elif re.search(r"^\w+$", s):
51
+ return s
52
+ else:
53
+ return '"' + s.replace('"', r'\"').replace("\n", r'\n') + '"'
54
+
55
+
56
+ def jpath(path: JsonPath):
57
+ return "." + ".".join(sesc(s) for s in path)
58
+
59
+
60
+ def numberConstraints(schema):
61
+ assert "type" in schema and schema["type"] in ("integer", "number")
62
+ constraints = {}
63
+ if "multipleOf" in schema:
64
+ mo = schema["multipleOf"]
65
+ assert type(mo) in (int, float)
66
+ constraints[".mo"] = mo # extension
67
+ # assert False, "keyword multipleOf is not supported"
68
+ if "minimum" in schema:
69
+ mini = schema["minimum"]
70
+ assert type(mini) in (int, float)
71
+ constraints[">="] = mini
72
+ if "maximum" in schema:
73
+ maxi = schema["maximum"]
74
+ assert type(maxi) in (int, float)
75
+ constraints["<="] = maxi
76
+ if "exclusiveMinimum" in schema:
77
+ mini = schema["exclusiveMinimum"]
78
+ assert type(mini) in (int, float)
79
+ constraints[">"] = mini
80
+ if "exclusiveMaximum" in schema:
81
+ maxi = schema["exclusiveMaximum"]
82
+ assert type(maxi) in (int, float)
83
+ constraints["<"] = maxi
84
+ return constraints
85
+
86
+
87
+ def buildModel(model, constraints: dict, defs: dict, sharp: dict, is_root = False):
88
+ if constraints or sharp or defs:
89
+
90
+ if constraints:
91
+ m = {"@": model, **constraints}
92
+ elif isinstance(model, dict):
93
+ m = model
94
+ else:
95
+ m = {"@": model}
96
+
97
+ if sharp and "description" in sharp:
98
+ m["#"] = sharp["description"]
99
+ if defs:
100
+ m["$"] = defs
101
+ if is_root and "#" not in m:
102
+ m["#"] = "JSON Model generated from a JSON Schema with json-schema-utils"
103
+ return m
104
+ else:
105
+ if is_root and isinstance(model, dict) and "#" not in model:
106
+ model["#"] = "JSON Model generated from a JSON Schema with json-schema-utils"
107
+ return model
108
+
109
+
110
+ META_KEYS = [
111
+ "title", "description", "default", "examples", "deprecated", "readOnly", "writeOnly", "id",
112
+ "$schema", "$id", "$comment", "$dynamicAnchor",
113
+ # OLD?
114
+ "context", "notes",
115
+ # extensions and strange stuff?
116
+ "markdownDescription", "deprecationMessage", "scope", "body", "example", "private",
117
+ ]
118
+
119
+ IGNORE = META_KEYS + ["$defs", "definitions"]
120
+
121
+ # keywords specific to a type
122
+ SPLITS = {
123
+ "number": ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"],
124
+ "string": ["minLength", "maxLength", "pattern"],
125
+ "array": ["minItems", "maxItems", "uniqueItems", "items", "prefixItems", "contains",
126
+ "minContains", "maxContains"],
127
+ "object": ["properties", "required", "additionalProperties", "minProperties", "maxProperties",
128
+ "patternProperties", "propertyNames"],
129
+ }
130
+
131
+ SPLIT = {}
132
+ for k in SPLITS.keys():
133
+ for n in SPLITS[k]:
134
+ SPLIT[n] = k
135
+ # log.warning(f"SPLIT = {SPLIT}")
136
+
137
+
138
+ def split_schema(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
139
+ assert isinstance(schema, dict) and "type" in schema
140
+ types = schema["type"]
141
+ assert isinstance(types, (list, tuple))
142
+ # log.info(f"splitting on {types}")
143
+ # per type
144
+ schemas = {t: {"type": t} for t in types}
145
+ schemas[""] = {}
146
+ for prop, val in schema.items():
147
+ if prop in IGNORE:
148
+ schemas[""][prop] = val
149
+ elif prop in META_KEYS or prop == "type":
150
+ pass
151
+ elif prop == "format":
152
+ assert isinstance(val, str), "format is a string"
153
+ if "string" in schemas:
154
+ schemas["string"]["format"] = val
155
+ elif "number" in schemas:
156
+ schemas["number"]["format"] = val
157
+ elif "integer" in schemas:
158
+ schemas["integer"]["format"] = val
159
+ else:
160
+ assert False, f"cannot map format to {types}"
161
+ elif prop == "enum":
162
+ # DEAD CODE… enum does not need any check and is already before?
163
+ assert isinstance(val, list), "enum is a list"
164
+ for _, sh in schemas:
165
+ sh["enum"] = []
166
+ for v in val:
167
+ if v is None and "null" in schemas:
168
+ # just drop the enum
169
+ del schemas["null"]["enum"]
170
+ elif isinstance(v, bool) and "boolean" in schemas:
171
+ schemas["boolean"]["enum"].append(v)
172
+ elif isinstance(v, int):
173
+ if "integer" in schemas:
174
+ schemas["integer"]["enum"].append(v)
175
+ if "number" in schemas:
176
+ schemas["number"]["enum"].append(float(v))
177
+ elif isinstance(v, float):
178
+ if "number" in schemas:
179
+ schemas["number"]["enum"].append(v)
180
+ if "integer" in schemas and v - int(v) == 0.0:
181
+ schemas["integer"]["enum"].append(int(v))
182
+ elif isinstance(v, str) and "string" in schemas:
183
+ schemas["string"]["enum"].append(v)
184
+ elif isinstance(v, list) and "array" in schemas:
185
+ schemas["array"]["enum"].append(v)
186
+ elif isinstance(v, dict) and "object" in schemas:
187
+ schemas["object"]["enum"].append(v)
188
+ # else just ignore incompatible value…
189
+ elif prop in SPLIT:
190
+ assert SPLIT[prop] in types
191
+ schemas[SPLIT[prop]][prop] = val
192
+ else:
193
+ assert False, f"cannot map {prop} to a type"
194
+ # log.debug(f"splitted: {schemas}")
195
+ return schemas
196
+
197
+
198
+ # identifiers
199
+ CURRENT_SCHEMA: str|None = None
200
+ SCHEMA: str|None = None
201
+ IDS: dict[str, dict[str, Any]] = {}
202
+ EXPLICIT_TYPE: bool = False
203
+
204
+
205
+ def reset():
206
+ global CURRENT_SCHEMA, IDS, SCHEMA
207
+ CURRENT_SCHEMA = None
208
+ SCHEMA = None
209
+ IDS = {}
210
+
211
+
212
+ _FMT2MODEL = {
213
+ "password": "$STRING", # OpenAPI
214
+ "date": "$DATE",
215
+ "date-time": "$DATETIME",
216
+ "time": "$TIME",
217
+ "duration": "$DURATION",
218
+ "email": "$EMAIL",
219
+ "idn-email": "$EMAIL",
220
+ "hostname": "$HOSTNAME",
221
+ "idn-hostname": "$HOSTNAME",
222
+ "ipv4": "$IP4",
223
+ "ipv6": "$IP6",
224
+ "uri": "$URI",
225
+ "iri": "$URI",
226
+ "iri-reference": "$URI",
227
+ "uri-reference": "$URI",
228
+ "uri-template": "$URI",
229
+ "uuid": "$UUID",
230
+ "json-pointer": "$JSONPT",
231
+ "relative-json-pointer": "$JSONPT",
232
+ "regex": "$REGEX",
233
+ # hmmm…
234
+ "color": "$STRING",
235
+ "phone": "$STRING",
236
+ }
237
+
238
+
239
+ def format2model(fmt: str):
240
+ if fmt in _FMT2MODEL:
241
+ return _FMT2MODEL[fmt]
242
+ else:
243
+ log.warning(f"unknow format: {fmt}")
244
+ # return f"$UNKNOWN:{fmt}"
245
+ return ""
246
+
247
+ def doubt(ok: bool, msg: str, strict: bool):
248
+ if not ok:
249
+ if strict:
250
+ assert False, msg
251
+ else:
252
+ log.warning(msg)
253
+
254
+
255
+ def allOfLayer(schema: dict, operator: str):
256
+ # log.warning(f"ao {operator} in: {schema}")
257
+ schemas = copy.deepcopy(schema[operator])
258
+ del schema[operator]
259
+ # extract ignoreables
260
+ nschema = {}
261
+ for k, v in list(schema.items()):
262
+ if k in IGNORE:
263
+ nschema[k] = v
264
+ del schema[k]
265
+ # forward type just in case for *Of
266
+ if "type" in schema and isinstance(schema["type"], str) and isinstance(schemas, list):
267
+ t = schema["type"]
268
+ for s in schemas:
269
+ if isinstance(s, dict) and "type" not in s:
270
+ s["type"] = t
271
+ nschema["allOf"] = [{operator: schemas}, schema]
272
+ return nschema
273
+
274
+
275
+ # TODO handle a global defs so as to be able to create new ones
276
+ def schema2model(schema, path: JsonPath = [], strict: bool = True, is_root: bool = True):
277
+ """Convert a JSON schema to a JSON model assuming a 2020-12 semantics."""
278
+
279
+ global CURRENT_SCHEMA, SCHEMA
280
+
281
+ # 4.3.2 Boolean JSON Schemas
282
+ if isinstance(schema, bool):
283
+ return "$ANY" if schema else "$NONE"
284
+
285
+ spath: str = jpath(path)
286
+
287
+ assert isinstance(schema, dict), f"is an object at [{spath}]"
288
+
289
+ if "$schema" in schema:
290
+ sname = schema["$schema"]
291
+ if CURRENT_SCHEMA is not None and CURRENT_SCHEMA != sname:
292
+ log.warning(f"distinct nested $schema: {sname} at [{spath}]")
293
+ CURRENT_SCHEMA = sname
294
+
295
+ if SCHEMA is None:
296
+ # COPY because schema may be changed… (hmmm…)
297
+ SCHEMA = copy.deepcopy(schema)
298
+
299
+ # handle metadata
300
+ sharp = {}
301
+ for prop in META_KEYS:
302
+ if prop in schema:
303
+ sharp[prop] = schema[prop]
304
+
305
+ # handle defs
306
+ # FIXME should delay conversion...
307
+ defs = {}
308
+ if "$defs" in schema or "definitions" in schema:
309
+ dname = "$defs" if "$defs" in schema else "definitions"
310
+ IDS[dname] = {}
311
+ _defs = schema[dname]
312
+ assert isinstance(_defs, dict)
313
+ for name, val in _defs.items():
314
+ log.info(f"registering {dname}/{name} at [{spath}]")
315
+ # if name is ugly, $ref are encoded, we encode here as well
316
+ if "/" in name or "%" in name:
317
+ encoded = quote(name).replace("~", "~0").replace("/", "~1")
318
+ else:
319
+ encoded = name
320
+ # keep json schema for handling $ref #
321
+ IDS[dname][encoded] = val
322
+ # provide a local converted version as well? not enough??
323
+ defs[encoded] = schema2model(val, path + [dname, name], strict, False)
324
+ # special root handling
325
+ IDS[""] = "$#"
326
+
327
+ # FIXME cleanup OpenAPI extentions "x-*", nullable
328
+ for prop in list(schema.keys()):
329
+ if prop.startswith("x-"):
330
+ log.warning(f"deleting {prop} at [{spath}]")
331
+ del schema[prop]
332
+ if "nullable" in schema:
333
+ nullable = schema["nullable"]
334
+ assert isinstance(nullable, bool)
335
+ if nullable:
336
+ if "type" in schema and isinstance(schema["type"], str):
337
+ schema["type"] = [schema["type"], "null"]
338
+ log.warning(f"ignoring nullable directive at [{spath}]")
339
+ del schema["nullable"]
340
+
341
+ # FIX missing type in some cases
342
+ if "type" not in schema:
343
+ if "properties" in schema or "required" in schema or "additionalProperties" in schema:
344
+ schema["type"] = "object"
345
+ elif "pattern" in schema or "maxLength" in schema or "minLength" in schema:
346
+ schema["type"] = "string"
347
+ elif "items" in schema or "minItems" in schema or "maxItems" in schema:
348
+ schema["type"] = "array"
349
+
350
+ # translate if/then/else to and/xor/not
351
+ # TODO move/replicate in simplify?
352
+ # NOTE this induces some code expansion
353
+ # NOTE generated structures may be simplified later
354
+ if "then" in schema or "else" in schema:
355
+ if "if" not in schema:
356
+ # no if => then/else are ignored (10.2.2)
357
+ log.warning(f"ignoring then/else without if at [{spath}]")
358
+ if "else" in schema:
359
+ del schema["else"]
360
+ if "then" in schema:
361
+ del schema["then"]
362
+ else: # if in schema
363
+ sif = schema["if"]
364
+ del schema["if"]
365
+ # possibly add type to help convertion
366
+ if isinstance(sif, dict) and "type" not in sif:
367
+ for t in SPLITS.keys():
368
+ if only(sif, *SPLITS[t], *IGNORE):
369
+ sif["type"] = t
370
+ break
371
+ if isinstance(sif, dict) and "type" in sif and isinstance(sif["type"], str):
372
+ type_sif = sif["type"]
373
+ else:
374
+ type_sif = None
375
+ if "then" in schema:
376
+ sthen = schema["then"]
377
+ del schema["then"]
378
+ if type_sif and isinstance(sthen, dict) and "type" not in sthen:
379
+ sthen["type"] = type_sif
380
+ else:
381
+ sthen = True
382
+ if "else" in schema:
383
+ selse = schema["else"]
384
+ del schema["else"]
385
+ if type_sif and isinstance(selse, dict) and "type" not in selse:
386
+ selse["type"] = type_sif
387
+ else:
388
+ selse = True
389
+ # W, if: X, then: Y, else: Z => (W and ((X and Y) x?or (not X and Z))
390
+ # separate meta keywords from significant keywords
391
+ subschema = {}
392
+ for k in list(schema.keys()):
393
+ if k not in IGNORE:
394
+ subschema[k] = schema[k]
395
+ del schema[k]
396
+ if "allOf" not in schema:
397
+ schema["allOf"] = []
398
+ schema["allOf"].append(subschema)
399
+ schema["allOf"].append(
400
+ {
401
+ "anyOf": [
402
+ { "allOf": [ sif, sthen ] },
403
+ { "allOf": [ { "not": copy.deepcopy(sif) }, selse ] }
404
+ ]
405
+ }
406
+ )
407
+ elif "if" in schema:
408
+ log.warning(f"ignoring lone if at [{spath}]")
409
+ del schema["if"]
410
+
411
+ # FIXME adhoc handling for table-schema.json and ADEME and others
412
+ # FIXME maybe this is not needed anymore?
413
+ if "type" in schema and schema["type"] == "object" and ("anyOf" in schema or "oneOf" in schema):
414
+ log.warning(f"distributing object on anyOf/oneOf at [{spath}]")
415
+ # special case for Ademe
416
+ if "anyOf" in schema:
417
+ assert "oneOf" not in schema
418
+ lof = schema["anyOf"]
419
+ elif "oneOf" in schema:
420
+ assert "anyOf" not in schema
421
+ lof = schema["oneOf"]
422
+ else:
423
+ assert False # dead code
424
+ # transfer type in list
425
+ del schema["type"]
426
+ for s in lof:
427
+ assert isinstance(s, dict)
428
+ s["type"] = "object"
429
+ if "required" in schema:
430
+ required = schema["required"]
431
+ del schema["required"]
432
+ for s in lof:
433
+ if "required" in s:
434
+ s["required"].append(required)
435
+ else:
436
+ s["required"] = required
437
+ if "properties" in schema:
438
+ props = schema["properties"]
439
+ del schema["properties"]
440
+ for s in lof:
441
+ if "properties" in s:
442
+ s["properties"].update(props)
443
+ else:
444
+ s["properties"] = props
445
+ if "additionalProperties" in schema:
446
+ addprop = schema["additionalProperties"]
447
+ del schema["additionalProperties"]
448
+ for s in lof:
449
+ # cold overwrite, should warn
450
+ s["additionalProperties"] = addprop
451
+ if "patternProperties" in schema:
452
+ pp = schema["patternProperties"]
453
+ del schema["patternProperties"]
454
+ for s in lof:
455
+ # cold overwrite, should warn
456
+ s["patternProperties"] = pp
457
+
458
+ if "type" in schema and ("allOf" in schema or "anyOf" in schema or "oneOf" in schema or
459
+ "enum" in schema or "$ref" in schema):
460
+ log.warning("removing type from constructed schema?")
461
+ del schema["type"]
462
+
463
+ # structures
464
+ if "oneOf" in schema:
465
+ choices = schema["oneOf"]
466
+ assert isinstance(choices, list), f"oneOf list at [{spath}]"
467
+ if only(schema, "oneOf", *IGNORE):
468
+ model = {"^": [schema2model(s, path + ["oneOf", i], strict, False)
469
+ for i, s in enumerate(choices)]}
470
+ return buildModel(model, {}, defs, sharp, is_root)
471
+ else: # try building an "allOf" layer
472
+ log.warning(f"keyword oneOf intermixed with other keywords at [{spath}]")
473
+ ao = allOfLayer(schema, "oneOf")
474
+ return schema2model(ao, path + ["oneOf"], strict, False)
475
+ elif "anyOf" in schema:
476
+ choices = schema["anyOf"]
477
+ assert isinstance(choices, (list, tuple)), f"anyOf list at [{spath}]"
478
+ if only(schema, "anyOf", *IGNORE):
479
+ model = {"|": [schema2model(s, path + ["anyOf", i], strict, False)
480
+ for i, s in enumerate(choices)]}
481
+ return buildModel(model, {}, defs, sharp, is_root)
482
+ else:
483
+ log.warning(f"keyword anyOf intermixed with other keywords at [{spath}]")
484
+ ao = allOfLayer(schema, "anyOf")
485
+ return schema2model(ao, path + ["anyOf"], strict, False)
486
+ elif "allOf" in schema:
487
+ # NOTE types should be compatible to avoid an empty match
488
+ choices = schema["allOf"]
489
+ assert isinstance(choices, (list, tuple)), f"allOf list at [{spath}]"
490
+ if only(schema, "allOf", *IGNORE):
491
+ model = {"&": [schema2model(s, path + ["allOf", i], strict, False)
492
+ for i, s in enumerate(choices)]}
493
+ return buildModel(model, {}, defs, sharp, is_root)
494
+ else: # build another allOf layer
495
+ log.warning(f"keyword allOf intermixed with other keywords at [{spath}]")
496
+ ao = allOfLayer(schema, "allOf")
497
+ return schema2model(ao, path + ["allOf"], strict, False)
498
+ elif "not" in schema:
499
+ val = schema["not"]
500
+ assert isinstance(val, dict), "not object at [{spath}]"
501
+ if only(schema, "not", *IGNORE):
502
+ if len(val) == 0:
503
+ model = "$NONE"
504
+ else:
505
+ model = {"^": ["$ANY", schema2model(val, path + ["not"], strict, False)]}
506
+ return buildModel(model, {}, defs, sharp, is_root)
507
+ else: # add a allOf layer
508
+ log.warning(f"keyword not intermixed with other keywords at [{spath}]")
509
+ ao = allOfLayer(schema, "not")
510
+ return schema2model(ao, path + ["not"], strict, False)
511
+
512
+ # handle simpler schemas
513
+ if "$ref" in schema:
514
+ if only(schema, "$ref", *IGNORE):
515
+ ref = schema["$ref"]
516
+ assert isinstance(ref, str) and len(ref) > 0
517
+ if ref in ("#/", "#"):
518
+ return "$#"
519
+
520
+ name = None
521
+ if ref.startswith("#/$defs/") and only(schema, "$ref", *IGNORE):
522
+ # keep a reference if simple
523
+ name = ref[8:]
524
+ elif ref.startswith("#/definitions/") and only(schema, "$ref", *IGNORE):
525
+ name = ref[14:]
526
+ if name is not None and "/" not in name:
527
+ return buildModel("$" + name, {}, defs, sharp, is_root)
528
+
529
+ # else we have to navigate…
530
+ # FIXME should be useless? should have been simplified?
531
+ if ref.startswith("#/"):
532
+ names = ref[2:].split("/")
533
+ # standard /$def/foo
534
+ if names and names[0] in ("$defs", "definitions"):
535
+ val = IDS
536
+ for name in names:
537
+ assert name in val, f"following path in {ref}: missing {name} ({IDS}) at [{spath}]"
538
+ val = val[name]
539
+ else: # arbitrary path
540
+ val = SCHEMA
541
+ for name in names:
542
+ if "~0" in name or "~1" in name or "%" in name:
543
+ name = unquote(name).replace("~1", "/").replace("~0", "~")
544
+ assert name in val, f"following path in {ref}: missing {name} at [{spath}]"
545
+ val = val[name]
546
+ model = schema2model(val, path + ["$ref"], strict, False)
547
+ return buildModel(model, {}, defs, sharp, is_root)
548
+ else:
549
+ assert False, f"$ref handling not implemented: {ref}"
550
+ else:
551
+ log.warning(f"$ref intermixed with other keywords at [{spath}]")
552
+ return schema2model(allOfLayer(schema, "$ref"), path, strict, False)
553
+
554
+ elif "type" in schema:
555
+ ts = schema["type"]
556
+ if isinstance(ts, (list, tuple)):
557
+ schemas = split_schema(schema)
558
+ del schemas[""] # remove ignored stuff
559
+ return buildModel(
560
+ {
561
+ "|": [
562
+ schema2model(v, path + ["typeS"], strict, False) for v in schemas.values()
563
+ ]
564
+ }, {}, defs, sharp, is_root)
565
+ elif ts == "string" and "const" in schema:
566
+ doubt(only(schema, "type", "const", *IGNORE), f"string const at [{spath}]", strict)
567
+ const = schema["const"]
568
+ return buildModel(f"_{const}", {}, defs, sharp, is_root)
569
+ elif ts == "string":
570
+ doubt(only(schema, "enum", "type", "format", "pattern", "minLength", "maxLength",
571
+ "contentMediaType", "contentEncoding", *IGNORE),
572
+ f"string at [{spath}]", strict)
573
+ model = "$STRING" if EXPLICIT_TYPE else ""
574
+ if "format" in schema:
575
+ fmt = schema["format"]
576
+ if fmt is not None:
577
+ model = format2model(fmt)
578
+ else:
579
+ log.warning(f"ignoring null format at [{spath}]")
580
+ constraints = {}
581
+ if "enum" in schema:
582
+ ve = schema["enum"]
583
+ assert isinstance(ve, list), path
584
+ # TODO check stat *all* items are strings
585
+ if len(ve) == 1:
586
+ assert isinstance(ve[0], str), path
587
+ model = esc(ve[0])
588
+ else:
589
+ model = {"|": [esc(v) for v in ve]}
590
+ if "pattern" in schema:
591
+ pattern = schema["pattern"]
592
+ assert isinstance(pattern, str), f"string pattern at [{spath}]"
593
+ assert model in ("", "$STRING"), f"string pattern for string at [{spath}]"
594
+ model = f"/{pattern}/"
595
+ if "minLength" in schema and "maxLength" in schema and \
596
+ schema["minLength"] == schema["maxLength"]:
597
+ ival = schema["minLength"]
598
+ assert isinstance(ival, int), f"int length at [{spath}]"
599
+ constraints["="] = ival
600
+ else:
601
+ if "minLength" in schema:
602
+ minlen = schema["minLength"]
603
+ assert isinstance(minlen, int), f"int min length at [{spath}]"
604
+ constraints[">="] = minlen
605
+ if "maxLength" in schema:
606
+ maxlen = schema["maxLength"]
607
+ assert isinstance(maxlen, int), f"int max length at [{spath}]"
608
+ constraints["<="] = maxlen
609
+ # if "contentMediaType" in schema:
610
+ # val = schema["contentMediaType"]
611
+ # assert isinstance(val, str), path
612
+ # constraints["mime"] = val
613
+ # if "contentEncoding" in schema:
614
+ # val = schema["contentEncoding"]
615
+ # assert isinstance(val, str), path
616
+ # constraints["encoding"] = val
617
+ return buildModel(model, constraints, defs, sharp, is_root)
618
+ elif ts == "number":
619
+ doubt(only(schema, "type", "format", "multipleOf", "minimum", "maximum",
620
+ "exclusiveMinimum", "exclusiveMaximum", *IGNORE),
621
+ f"number properties at [{spath}]", strict)
622
+ model = "$NUMBER" if EXPLICIT_TYPE else -1.0
623
+ if "format" in schema:
624
+ fmt = schema["format"]
625
+ assert fmt in ("double", "float"), f"bad format {fmt} at [{spath}]"
626
+ if fmt == "double":
627
+ model = "$DOUBLE"
628
+ elif fmt == "float":
629
+ model = "$FLOAT"
630
+ else:
631
+ assert False, f"unexpected number format {fmt} at [{spath}]"
632
+ constraints = numberConstraints(schema)
633
+ return buildModel(model, constraints, defs, sharp, is_root)
634
+ elif ts == "integer" and "const" in schema:
635
+ ival = schema["const"]
636
+ if isinstance(ival, (int, float)) and ival == int(ival):
637
+ model = "={int(ival)}"
638
+ else:
639
+ model = "$NONE"
640
+ elif ts == "integer":
641
+ doubt(only(schema, "type", "format", "multipleOf", "minimum", "maximum",
642
+ "exclusiveMinimum", "exclusiveMaximum", *IGNORE),
643
+ f"integer properties at [{spath}]", strict)
644
+ model = "$INTEGER" if EXPLICIT_TYPE else -1
645
+ # OpenAPI
646
+ if "format" in schema:
647
+ fmt = schema["format"]
648
+ log.warning(f"ignoring format {fmt} at [{spath}]")
649
+ assert fmt in ("int32", "int64"), f"bad format {fmt} at [{spath}]"
650
+ if fmt == "int32":
651
+ model = "$I32"
652
+ elif fmt == "int64":
653
+ model = "$I64"
654
+ else:
655
+ assert False, f"unexpected integer format: {fmt} at [{spath}]"
656
+ constraints = numberConstraints(schema)
657
+ return buildModel(model, constraints, defs, sharp, is_root)
658
+ elif ts == "boolean":
659
+ if "required" in schema:
660
+ log.warning(f"'required' ignored for boolean at [{spath}]")
661
+ doubt(only(schema, "type", "required", *IGNORE),
662
+ f"boolean properties at [{spath}]", strict)
663
+ model = "$BOOL" if EXPLICIT_TYPE else True
664
+ return buildModel(model, {}, defs, sharp, is_root)
665
+ elif ts == "null":
666
+ model = "$NULL" if EXPLICIT_TYPE else None
667
+ doubt(only(schema, "type", *IGNORE), f"null with props at [{spath}]", strict)
668
+ return buildModel(model, {}, defs, sharp, is_root)
669
+
670
+ elif ts == "array":
671
+ # fix common misplacements
672
+ if not strict and "items" in schema and isinstance(schema["items"], dict):
673
+ subschema = schema["items"]
674
+ for kw in ("uniqueItems", "minItems", "maxItems"):
675
+ # possible move misplaced uniqueItems
676
+ if kw not in schema and kw in subschema and "type" in subschema and \
677
+ isinstance(subschema["type"], str) and subschema["type"] != "array":
678
+ log.warning(f"moving misplaced {kw} at [{spath}.items]")
679
+ schema[kw] = subschema[kw]
680
+ del subschema[kw]
681
+ # TODO minLength -> minItems
682
+
683
+ # sanity check
684
+ doubt(only(schema, "type", "prefixItems", "items",
685
+ "contains", "minContains", "maxContains",
686
+ "minItems", "maxItems", "uniqueItems", *IGNORE),
687
+ f"array properties at [{spath}]", strict)
688
+
689
+ # build model
690
+ constraints = {}
691
+ if "minItems" in schema and "maxItems" in schema and \
692
+ schema["minItems"] == schema["maxItems"]:
693
+ ival = schema["minItems"]
694
+ assert isinstance(ival, int)
695
+ constraints["="] = ival
696
+ else:
697
+ if "minItems" in schema:
698
+ mini = schema["minItems"]
699
+ assert type(mini) is int, f"int min items at [{spath}]"
700
+ constraints[">="] = mini
701
+ if "maxItems" in schema:
702
+ maxi = schema["maxItems"]
703
+ assert type(maxi) is int, f"int max items at [{spath}]"
704
+ constraints["<="] = maxi
705
+ if "uniqueItems" in schema:
706
+ unique = schema["uniqueItems"]
707
+ assert isinstance(unique, bool), f"boolean unique at [{spath}]"
708
+ if unique:
709
+ constraints["!"] = True
710
+ if "prefixItems" in schema:
711
+ doubt(only(schema, "type", "prefixItems", "items", "minItems", "maxItems",
712
+ "uniqueItems", *IGNORE),
713
+ f"array props with prefixItems at [{spath}]", strict)
714
+ # tuple
715
+ vpi = schema["prefixItems"]
716
+ assert isinstance(vpi, list), f"list prefixItems at [{spath}]"
717
+ model = [
718
+ schema2model(s, path + ["prefixItems", i], strict, False)
719
+ for i, s in enumerate(vpi)
720
+ ]
721
+ if "items" in schema:
722
+ # cas prefixItems + items
723
+ if isinstance(schema["items"], bool):
724
+ assert not constraints, f"not implemented yet at [{spath}]"
725
+ if not schema["items"]:
726
+ return buildModel(model, {">=": 0, "<=": len(model)}, defs, sharp, is_root)
727
+ else:
728
+ assert False, f"not implemented yet at [{spath}]"
729
+ # items is a type
730
+ model.append(schema2model(schema["items"], path + ["items"], strict, False))
731
+ if not constraints:
732
+ constraints[">="] = len(model) - 1
733
+ return buildModel(model, constraints, defs, sharp, is_root)
734
+ else:
735
+ model.append("$ANY")
736
+ if not constraints:
737
+ constraints[">="] = len(model) - 1
738
+ return buildModel(model, constraints, defs, sharp, is_root)
739
+ elif "items" in schema:
740
+ doubt(only(schema, "type", "items", "minItems", "maxItems", "uniqueItems",
741
+ *IGNORE), f"array props with items at [{spath}]", strict)
742
+ sitems = schema["items"]
743
+ if isinstance(sitems, list):
744
+ # OLD JSON Schema prefixItems…
745
+ array = [
746
+ schema2model(s, path + ["items", i], strict, False)
747
+ for i, s in enumerate(sitems)
748
+ ]
749
+ if strict:
750
+ return array
751
+ else: # trigger varlen tuple…
752
+ array.append("$ANY")
753
+ return { "@": array, ">=": 0 }
754
+ else:
755
+ assert isinstance(sitems, (dict, bool)), f"valid schema at [{spath}]"
756
+ model = [schema2model(schema["items"], path + ["items"], strict, False)]
757
+ return buildModel(model, constraints, defs, sharp, is_root)
758
+ elif "contains" in schema:
759
+ # NO contains/items mixing yet
760
+ assert only(schema, "type", "contains", "minContains", "maxContains",
761
+ "uniqueItems", *IGNORE), f"array props for containts[{spath}]"
762
+ # NOTE contains is not really supported in jm v2, or rather as an extension
763
+ model = {
764
+ "@": ["$ANY"],
765
+ ".in": schema2model(schema["contains"], path + ["contains"], strict, False)
766
+ }
767
+ if "minContains" in schema:
768
+ mini = schema["minContains"]
769
+ assert type(mini) is int, f"int min contains at [{spath}]"
770
+ model[">="] = mini
771
+ if "maxContains" in schema:
772
+ maxi = schema["maxContains"]
773
+ assert type(maxi) is int, f"int max contains at [{spath}]"
774
+ model["<="] = maxi
775
+ return buildModel(model, constraints, defs, sharp, is_root)
776
+ else:
777
+ return buildModel(["$ANY"], constraints, defs, sharp, is_root)
778
+
779
+ elif ts == "object":
780
+
781
+ # ignore OpenAPI extension
782
+ if "discriminator" in schema:
783
+ log.warning(f"ignoring discriminator at [{spath}]")
784
+ del schema["discriminator"]
785
+
786
+ # fix additionalProperties misplacement
787
+ if not strict and "properties" in schema and isinstance(schema["properties"], dict) \
788
+ and "additionalProperties" not in schema \
789
+ and "additionalProperties" in schema["properties"] \
790
+ and "properties" not in schema["properties"]:
791
+ log.warning(f"moving misplaced additionalProperties at [{spath}.properties]")
792
+ schema["additionalProperties"] = schema["properties"]["additionalProperties"]
793
+ del schema["properties"]["additionalProperties"]
794
+
795
+ # sanity check
796
+ doubt(only(schema, "type", "properties", "additionalProperties", "required",
797
+ "minProperties", "maxProperties", "patternProperties", "propertyNames",
798
+ *IGNORE), f"object properties at [{spath}]", strict)
799
+
800
+ # build model
801
+ constraints = {}
802
+ model = {}
803
+ if "minProperties" in schema and "maxProperties" in schema and \
804
+ schema["minProperties"] == schema["maxProperties"]:
805
+ ival = schema["maxProperties"]
806
+ assert isinstance(ival, int), f"int # props at [{spath}]"
807
+ else:
808
+ if "minProperties" in schema:
809
+ mini = schema["minProperties"]
810
+ assert type(mini) is int, f"int min props at [{spath}]"
811
+ constraints[">="] = mini
812
+ if "maxProperties" in schema:
813
+ maxi = schema["maxProperties"]
814
+ assert type(maxi) is int, f"int max props at [{spath}]"
815
+ constraints["<="] = maxi
816
+
817
+ if "patternProperties" in schema:
818
+ pats = schema["patternProperties"]
819
+ assert isinstance(pats, dict), f"dict pattern props at [{spath}]"
820
+ for pp in sorted(pats.keys()):
821
+ model[f"/{pp}/"] = \
822
+ schema2model(pats[pp], path + ["patternProperties", pp], strict, False)
823
+
824
+ if "propertyNames" in schema:
825
+ # does not seem very useful?
826
+ pnames = schema["propertyNames"]
827
+ if "additionalProperties" in schema:
828
+ target = schema2model(schema["additionalProperties"], path, strict, False)
829
+ else:
830
+ target = "$ANY"
831
+
832
+ # TODO for other cases, we could create a new reference
833
+ assert only(pnames, "pattern", "type", "format", *IGNORE), \
834
+ f"props for prop names at [{spath}]"
835
+ # if given a type, it must be string
836
+ if "type" in pnames:
837
+ assert pnames["type"] == "string", f"prop name is string at [{spath}]"
838
+ if "pattern" in pnames:
839
+ pat = pnames["pattern"]
840
+ assert isinstance(pat, str), f"pattern is string at [{spath}]"
841
+ model[f"/{pat}/"] = target
842
+ elif "format" in pnames:
843
+ fmt = pnames["format"]
844
+ model[format2model(fmt)] = target
845
+ else:
846
+ model[""] = target
847
+
848
+ if "properties" in schema:
849
+ props = schema["properties"]
850
+ required = schema.get("required", [])
851
+ assert isinstance(props, dict), f"dict properties [{spath}]"
852
+ for k, v in props.items():
853
+ if k in required:
854
+ model[f"_{k}"] = \
855
+ schema2model(v, path + ["properties", f"_{k}"], strict, False)
856
+ else:
857
+ model[f"?{k}"] = \
858
+ schema2model(v, path + ["properties", f"?{k}"], strict, False)
859
+ if "additionalProperties" in schema:
860
+ ap = schema["additionalProperties"]
861
+ if isinstance(ap, bool):
862
+ if ap:
863
+ model[""] = "$ANY"
864
+ # else nothing else is allowed
865
+ elif isinstance(ap, dict):
866
+ model[""] = schema2model(ap, path + ["additionalProperties"], strict, False)
867
+ else:
868
+ assert False, f"not implemented yet at [{spath}]"
869
+ else:
870
+ model[""] = "$ANY"
871
+
872
+ elif "additionalProperties" in schema:
873
+ # "additionalProperties" without "properties" or "patternProperties"
874
+ doubt(only(schema, "type", "additionalProperties", "maxProperties", "minProperties",
875
+ "properties", "patternProperties", *IGNORE),
876
+ f"add prop props at [{spath}]", strict)
877
+ if "properties" not in schema and "additionalProperties" not in schema and strict:
878
+ assert False, "additionalProperties without properties or patternProperties"
879
+ ap = schema["additionalProperties"]
880
+ if isinstance(ap, bool):
881
+ if ap:
882
+ model[""] = "$ANY"
883
+ # else nothing else is allowed
884
+ elif isinstance(ap, dict):
885
+ model[""] = schema2model(ap, path + ["additionalProperties"], strict, False)
886
+ else:
887
+ assert False, f"not implemented yet at [{spath}]"
888
+
889
+ elif "required" in schema:
890
+ # required without properties or additionalProperties
891
+ doubt(only(schema, "type", "required", "maxProperties", "minProperties",
892
+ *IGNORE), f"object props at [{spath}]", strict)
893
+ required = schema["required"]
894
+ assert isinstance(required, list) and all(isinstance(s, str) for s in required)
895
+ model[""] = "$ANY"
896
+ model.update({ f"_{k}": "$ANY" for k in required })
897
+ # what about other props?
898
+
899
+ else:
900
+ doubt(only(schema, "type", "maxProperties", "minProperties", "patternProperties",
901
+ "propertyNames", *IGNORE), f"object props at [{spath}]", strict)
902
+ if "propertyNames" in schema:
903
+ pass
904
+ else:
905
+ model[""] = "$ANY"
906
+ # handle constraints
907
+ return buildModel(model, constraints, defs, sharp, is_root)
908
+ else:
909
+ assert False, f"unexpected type: {ts} at [{spath}]"
910
+ elif "enum" in schema:
911
+ # FIXME check type value compatibility with other keywords, eg type?!
912
+ for prop in ("maxLength", "minLength"):
913
+ if prop in schema:
914
+ log.warning(f"ignoring doubtful {prop} from enum at [{spath}]")
915
+ del schema[prop]
916
+ doubt(only(schema, "enum", *IGNORE),
917
+ f"keyword enum intermixed with other keywords at [{spath}]", strict)
918
+ ve = schema["enum"]
919
+ assert isinstance(ve, list), f"enum list at [{spath}]"
920
+ if len(ve) == 1:
921
+ model = toconst(ve[0])
922
+ else:
923
+ model = {"|": [toconst(v) for v in ve]}
924
+ # vérifier les valeurs?
925
+ return buildModel(model, {}, defs, sharp, is_root)
926
+ elif "const" in schema:
927
+ assert only(schema, "const", *IGNORE), \
928
+ f"keyword const intermixed with other keywords at [{spath}]"
929
+ const = schema["const"]
930
+ # what is the type of the constant? assume a string for NOW, could be anything?
931
+ return buildModel(toconst(const), {}, defs, sharp, is_root)
932
+ else:
933
+ # empty schema
934
+ return buildModel("$ANY", {}, defs, sharp, is_root)