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.
- json_schema_utils-0.8.dist-info/METADATA +119 -0
- json_schema_utils-0.8.dist-info/RECORD +15 -0
- json_schema_utils-0.8.dist-info/WHEEL +5 -0
- json_schema_utils-0.8.dist-info/entry_points.txt +7 -0
- json_schema_utils-0.8.dist-info/licenses/LICENSE +1 -0
- json_schema_utils-0.8.dist-info/top_level.txt +1 -0
- jsutils/__init__.py +5 -0
- jsutils/convert.py +934 -0
- jsutils/inline.py +206 -0
- jsutils/recurse.py +90 -0
- jsutils/schemas.py +151 -0
- jsutils/scripts.py +396 -0
- jsutils/simplify.py +580 -0
- jsutils/stats.py +1310 -0
- jsutils/utils.py +44 -0
jsutils/scripts.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Any
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import argparse
|
|
8
|
+
from importlib.metadata import version as pkg_version
|
|
9
|
+
|
|
10
|
+
logging.basicConfig()
|
|
11
|
+
|
|
12
|
+
from .schemas import Schemas
|
|
13
|
+
from .utils import log, JSUError
|
|
14
|
+
from .recurse import hasDirectRef
|
|
15
|
+
from .inline import inlineRefs
|
|
16
|
+
from .simplify import simplifySchema, scopeDefs
|
|
17
|
+
from .stats import json_schema_stats, json_metrics, normalize_ods
|
|
18
|
+
|
|
19
|
+
__version__ = pkg_version("json_schema_utils")
|
|
20
|
+
|
|
21
|
+
def ap_common(ap, with_json=True):
|
|
22
|
+
ap.add_argument("--version", action="store_true", help="show version")
|
|
23
|
+
ap.add_argument("--debug", "-d", action="store_true", help="debug mode")
|
|
24
|
+
ap.add_argument("--quiet", "-q", action="store_true", help="quiet mode")
|
|
25
|
+
if with_json:
|
|
26
|
+
ap.add_argument("--indent", "-i", type=int, default=2, help="json indentation")
|
|
27
|
+
ap.add_argument("--sort-keys", "-s", default=True,
|
|
28
|
+
action="store_true", help="json sort keys")
|
|
29
|
+
ap.add_argument("--no-sort-keys", "-ns", dest="sort_keys",
|
|
30
|
+
action="store_false", help="json sort keys")
|
|
31
|
+
ap.add_argument("--ascii", action="store_true", default=False, help="json ensure ascii")
|
|
32
|
+
ap.add_argument("--no-ascii", dest="ascii", action="store_false",
|
|
33
|
+
help="no json ensure ascii")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def json_dumps(j: Any, args):
|
|
37
|
+
return json.dumps(j, indent=args.indent, sort_keys=args.sort_keys, ensure_ascii=args.ascii)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rm_suffix(s, *suffixes):
|
|
41
|
+
for suffix in suffixes:
|
|
42
|
+
if s.endswith(suffix):
|
|
43
|
+
return s[:-len(suffix)]
|
|
44
|
+
return s
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def jsu_inline():
|
|
48
|
+
"""Inline command entry point."""
|
|
49
|
+
|
|
50
|
+
ap = argparse.ArgumentParser()
|
|
51
|
+
ap_common(ap)
|
|
52
|
+
ap.add_argument("--map", "-m", action="append", help="url local mapping")
|
|
53
|
+
ap.add_argument("--auto", "-a", action="store_true", help="automatic url mapping")
|
|
54
|
+
ap.add_argument("schemas", nargs="*", help="schemas to inline")
|
|
55
|
+
args = ap.parse_args()
|
|
56
|
+
|
|
57
|
+
log.setLevel(logging.DEBUG if args.debug else logging.WARNING if args.quiet else logging.INFO)
|
|
58
|
+
|
|
59
|
+
if not args.schemas:
|
|
60
|
+
args.schemas = ["-"]
|
|
61
|
+
|
|
62
|
+
schemas = Schemas()
|
|
63
|
+
schemas.addProcess(lambda s, u: inlineRefs(s, u, schemas))
|
|
64
|
+
|
|
65
|
+
if args.map:
|
|
66
|
+
for m in args.map:
|
|
67
|
+
url, target = m.split(" ", 1)
|
|
68
|
+
schemas.addMap(url, target)
|
|
69
|
+
|
|
70
|
+
for fn in args.schemas:
|
|
71
|
+
log.debug(f"considering file: {fn}")
|
|
72
|
+
schema = json.load(open(fn) if fn != "-" else sys.stdin)
|
|
73
|
+
if isinstance(schema, bool):
|
|
74
|
+
inlined = schema
|
|
75
|
+
elif isinstance(schema, dict):
|
|
76
|
+
url = schema.get("$id", fn)
|
|
77
|
+
if args.auto and url != fn:
|
|
78
|
+
# https://schema.gouv.fr/stuff ./stuff(.schema.json)
|
|
79
|
+
u = rm_suffix(url, ".schema.json", ".json")
|
|
80
|
+
f = rm_suffix(fn, ".schema.json", ".json")
|
|
81
|
+
while u and f and u[-1] == f[-1]:
|
|
82
|
+
u, f = u[:-1], f[:-1]
|
|
83
|
+
if u and f:
|
|
84
|
+
log.info(f"map: {u} -> {f}")
|
|
85
|
+
schemas.addMap(u, f)
|
|
86
|
+
|
|
87
|
+
schemas.store(url, schema)
|
|
88
|
+
|
|
89
|
+
# cleanup definitions
|
|
90
|
+
inlined = copy.deepcopy(schemas.schema(url, "#"))
|
|
91
|
+
if isinstance(inlined, dict) and "$defs" in inlined and not hasDirectRef(inlined, url):
|
|
92
|
+
del inlined["$defs"]
|
|
93
|
+
else:
|
|
94
|
+
raise JSUError(f"invalid JSON Schema: {fn}")
|
|
95
|
+
|
|
96
|
+
print(json_dumps(inlined, args))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def jsu_simpler():
|
|
100
|
+
|
|
101
|
+
ap = argparse.ArgumentParser()
|
|
102
|
+
ap_common(ap)
|
|
103
|
+
ap.add_argument("schemas", nargs="*", help="schemas to simplify")
|
|
104
|
+
args = ap.parse_args()
|
|
105
|
+
|
|
106
|
+
if not args.schemas:
|
|
107
|
+
args.schemas = ["-"]
|
|
108
|
+
|
|
109
|
+
log.setLevel(logging.DEBUG if args.debug else logging.WARNING if args.quiet else logging.INFO)
|
|
110
|
+
|
|
111
|
+
for fn in args.schemas:
|
|
112
|
+
log.debug(f"considering file: {fn}")
|
|
113
|
+
schema = json.load(open(fn) if fn != "-" else sys.stdin)
|
|
114
|
+
if isinstance(schema, dict):
|
|
115
|
+
scopeDefs(schema)
|
|
116
|
+
schema = simplifySchema(schema, schema.get("$id", "."))
|
|
117
|
+
|
|
118
|
+
print(json_dumps(schema, args))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def jsu_check():
|
|
122
|
+
|
|
123
|
+
from .stats import SCHEMA_KEYS
|
|
124
|
+
|
|
125
|
+
ap = argparse.ArgumentParser()
|
|
126
|
+
ap_common(ap)
|
|
127
|
+
ap.add_argument("--draft", "-D", default="2020-12", help="JSON Schema draft")
|
|
128
|
+
ap.add_argument("--engine", "-e", choices=["jsonschema", "jschon"], default="jsonschema",
|
|
129
|
+
help="select JSON Schema implementation")
|
|
130
|
+
ap.add_argument("--force", action="store_true", help="accept any JSON as a schema")
|
|
131
|
+
ap.add_argument("--test", "-t", action="store_true", help="test vector mode")
|
|
132
|
+
ap.add_argument("schema", type=str, help="JSON Schema")
|
|
133
|
+
ap.add_argument("values", nargs="*", help="values to match against schema")
|
|
134
|
+
args = ap.parse_args()
|
|
135
|
+
|
|
136
|
+
log.setLevel(logging.DEBUG if args.debug else logging.WARNING if args.quiet else logging.INFO)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
with open(args.schema) if args.schema != "-" else sys.stdin as f:
|
|
140
|
+
jschema = json.load(f)
|
|
141
|
+
except FileNotFoundError as e:
|
|
142
|
+
if args.debug:
|
|
143
|
+
log.error(e, exc_info=args.debug)
|
|
144
|
+
print(f"{args.schema}: FILE ERROR ({e})")
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
except BaseException as e:
|
|
147
|
+
if args.debug:
|
|
148
|
+
log.error(e, exc_info=args.debug)
|
|
149
|
+
print(f"{args.schema}: JSON ERROR ({e})")
|
|
150
|
+
sys.exit(2)
|
|
151
|
+
|
|
152
|
+
# sanity check…
|
|
153
|
+
if not isinstance(jschema, (bool, dict)):
|
|
154
|
+
print(f"{args.schema}: SCHEMA TYPE ERROR")
|
|
155
|
+
sys.exit(3)
|
|
156
|
+
if isinstance(jschema, dict) and not (SCHEMA_KEYS & jschema.keys()):
|
|
157
|
+
if args.force:
|
|
158
|
+
log.warning(f"{args.schema}: json probably not a schema")
|
|
159
|
+
# go on, per spec…
|
|
160
|
+
else:
|
|
161
|
+
log.error(f"{args.schema}: json probably not a schema, use --force to proceed anyway")
|
|
162
|
+
print(f"{args.schema}: SCHEMA ERROR - not a schema, use --force to proceed anyway")
|
|
163
|
+
sys.exit(4)
|
|
164
|
+
|
|
165
|
+
# be nice
|
|
166
|
+
if isinstance(jschema, dict) and "$schema" not in jschema:
|
|
167
|
+
jschema["$schema"] = f"https://json-schema.org/draft/{args.version}/schema"
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
if args.engine == "jschon":
|
|
171
|
+
import jschon
|
|
172
|
+
jschon.create_catalog(args.version)
|
|
173
|
+
schema = jschon.JSONSchema(jschema)
|
|
174
|
+
|
|
175
|
+
def check(data):
|
|
176
|
+
res = schema.evaluate(jschon.JSON(data))
|
|
177
|
+
return { "passed": res.passed, "errors": res.output("basic") }
|
|
178
|
+
else:
|
|
179
|
+
import jsonschema
|
|
180
|
+
schema = jsonschema.Draft202012Validator(
|
|
181
|
+
jschema, format_checker=jsonschema.FormatChecker())
|
|
182
|
+
|
|
183
|
+
def check(data):
|
|
184
|
+
errors = list(e.message for e in schema.iter_errors(data))
|
|
185
|
+
return { "passed": len(errors) == 0, "errors": errors }
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
if args.debug:
|
|
189
|
+
log.error(e, exc_info=not args.quiet)
|
|
190
|
+
print(f"{args.schema}: SCHEMA ERROR ({e})")
|
|
191
|
+
sys.exit(5)
|
|
192
|
+
|
|
193
|
+
def check_data(name: str, data, expect: bool|None) -> bool:
|
|
194
|
+
res = check(data)
|
|
195
|
+
okay = res["passed"]
|
|
196
|
+
success = expect is None or okay == expect
|
|
197
|
+
if success:
|
|
198
|
+
print(f"{name}: {'PASS' if okay else 'FAIL'}")
|
|
199
|
+
else: # res != expect
|
|
200
|
+
print(f"{name}: ERROR unexpected {'PASS' if okay else 'FAIL'}")
|
|
201
|
+
if not okay and not args.quiet:
|
|
202
|
+
log.error(json_dumps(res["errors"], args))
|
|
203
|
+
return success
|
|
204
|
+
|
|
205
|
+
nerrors = 0
|
|
206
|
+
for fn in args.values:
|
|
207
|
+
with open(fn) if fn != "-" else sys.stdin as f:
|
|
208
|
+
try:
|
|
209
|
+
data = json.load(f)
|
|
210
|
+
if args.test:
|
|
211
|
+
ntests = 0
|
|
212
|
+
assert isinstance(data, list)
|
|
213
|
+
for item in data:
|
|
214
|
+
if isinstance(item, str): # comment
|
|
215
|
+
continue
|
|
216
|
+
assert len(item) in (2, 3)
|
|
217
|
+
name = f"{fn}[{ntests}]"
|
|
218
|
+
ntests += 1
|
|
219
|
+
if len(item) == 2:
|
|
220
|
+
expect, data = item
|
|
221
|
+
case = ""
|
|
222
|
+
else:
|
|
223
|
+
expect, case, data = item
|
|
224
|
+
assert isinstance(expect, bool) and isinstance(case, str)
|
|
225
|
+
if not check_data(name, data, expect):
|
|
226
|
+
nerrors += 1
|
|
227
|
+
else:
|
|
228
|
+
if not check_data(fn, data, None):
|
|
229
|
+
nerrors += 1
|
|
230
|
+
except Exception as e:
|
|
231
|
+
nerrors += 1
|
|
232
|
+
log.debug(e, exc_info=True)
|
|
233
|
+
print(f"{fn}: ERROR ({e})")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def shash(s: str):
|
|
237
|
+
return hashlib.sha3_256(s.encode()).hexdigest()[:20]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def jsu_stats():
|
|
241
|
+
|
|
242
|
+
ap = argparse.ArgumentParser()
|
|
243
|
+
ap_common(ap)
|
|
244
|
+
ap.add_argument("schemas", nargs="*", help="JSON Schema to analyze")
|
|
245
|
+
args = ap.parse_args()
|
|
246
|
+
|
|
247
|
+
log.setLevel(logging.DEBUG if args.debug else logging.WARNING if args.quiet else logging.INFO)
|
|
248
|
+
|
|
249
|
+
if not args.schemas:
|
|
250
|
+
args.schemas = ["-"]
|
|
251
|
+
|
|
252
|
+
for fn in args.schemas:
|
|
253
|
+
log.info(f"considering: {fn}")
|
|
254
|
+
with open(fn) if fn != "-" else sys.stdin as f:
|
|
255
|
+
try:
|
|
256
|
+
# raw data and its hash
|
|
257
|
+
data = f.read()
|
|
258
|
+
jdata = json.loads(data)
|
|
259
|
+
|
|
260
|
+
# JSON Schema specific stats
|
|
261
|
+
stats = json_schema_stats(jdata)
|
|
262
|
+
small: dict[str, Any] = {
|
|
263
|
+
k: v for k, v in stats.items() if v or isinstance(v, bool)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# basic JSON structural stats
|
|
267
|
+
small["<json-metrics>"] = json_metrics(jdata)
|
|
268
|
+
|
|
269
|
+
# normalized version with its hash
|
|
270
|
+
normalize_ods(fn, jdata) # OpenDataSoft generated schemas
|
|
271
|
+
normed = json.dumps(jdata, sort_keys=True, indent=None, ensure_ascii=True)
|
|
272
|
+
small["<normed-hash>"] = shash(normed)
|
|
273
|
+
|
|
274
|
+
small["<input-file>"] = fn
|
|
275
|
+
small["<file-hash>"] = shash(data)
|
|
276
|
+
|
|
277
|
+
print(json_dumps(small, args))
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
log.error(f"{fn}: {e}", exc_info=True)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def jsu_pretty():
|
|
284
|
+
|
|
285
|
+
ap = argparse.ArgumentParser()
|
|
286
|
+
ap_common(ap)
|
|
287
|
+
ap.add_argument("schemas", nargs="*", help="schemas to inline")
|
|
288
|
+
args = ap.parse_args()
|
|
289
|
+
|
|
290
|
+
log.setLevel(logging.DEBUG if args.debug else logging.WARNING if args.quiet else logging.INFO)
|
|
291
|
+
|
|
292
|
+
if not args.schemas:
|
|
293
|
+
args.schemas = ["-"]
|
|
294
|
+
|
|
295
|
+
for fn in args.schemas:
|
|
296
|
+
log.debug(f"considering file: {fn}")
|
|
297
|
+
schema = json.load(open(fn) if fn != "-" else sys.stdin)
|
|
298
|
+
print(json_dumps(schema, args))
|
|
299
|
+
|
|
300
|
+
GH = "https://raw.githubusercontent.com"
|
|
301
|
+
SS = "https://json.schemastore.org"
|
|
302
|
+
# JM = f"{GH}/clairey-zx81/json-model/main/models"
|
|
303
|
+
JM = "https://json-model.org/models"
|
|
304
|
+
|
|
305
|
+
# Schema $id/id to Model URL
|
|
306
|
+
ID2MODEL: dict[str, tuple[str, str]] = {
|
|
307
|
+
# JSON Schema drafts
|
|
308
|
+
"http://json-schema.org/draft-04/schema": (
|
|
309
|
+
f"{JM}/json-schema-draft-04.model.json",
|
|
310
|
+
f"{JM}/json-schema-draft-04-fuzzy.model.json",
|
|
311
|
+
),
|
|
312
|
+
"http://json-schema.org/draft-06/schema": (
|
|
313
|
+
f"{JM}/json-schema-draft-06.model.json",
|
|
314
|
+
f"{JM}/json-schema-draft-06-fuzzy.model.json",
|
|
315
|
+
),
|
|
316
|
+
"http://json-schema.org/draft-07/schema": (
|
|
317
|
+
f"{JM}/json-schema-draft-07.model.json",
|
|
318
|
+
f"{JM}/json-schema-draft-07-fuzzy.model.json",
|
|
319
|
+
),
|
|
320
|
+
"https://json-schema.org/draft/2019-09/schema": (
|
|
321
|
+
f"{JM}/json-schema-draft-2019-09.model.json",
|
|
322
|
+
f"{JM}/json-schema-draft-2019-09-fuzzy.model.json",
|
|
323
|
+
),
|
|
324
|
+
"https://json-schema.org/draft/2020-12/schema": (
|
|
325
|
+
f"{JM}/json-schema-draft-2020-12.model.json",
|
|
326
|
+
f"{JM}/json-schema-draft-2020-12-fuzzy.model.json",
|
|
327
|
+
),
|
|
328
|
+
# Miscellaneous models
|
|
329
|
+
f"{GH}/ansible/ansible-lint/main/src/ansiblelint/schemas/meta.json": (
|
|
330
|
+
f"{JM}/ansiblelint-meta.model.json",
|
|
331
|
+
f"{JM}/ansiblelint-meta.model.json",
|
|
332
|
+
),
|
|
333
|
+
"https://geojson.org/schema/GeoJSON.json": (
|
|
334
|
+
f"{JM}/geo.model.json",
|
|
335
|
+
f"{JM}/geo.model.json",
|
|
336
|
+
),
|
|
337
|
+
f"{SS}/lazygit.json": (
|
|
338
|
+
f"{JM}/lazygit.model.json",
|
|
339
|
+
f"{JM}/lazygit.model.json",
|
|
340
|
+
),
|
|
341
|
+
"https://spec.openapis.org/oas/3.1/schema/2022-10-07": (
|
|
342
|
+
f"{JM}/openapi-311.model.json",
|
|
343
|
+
f"{JM}/openapi-311.model.json", # TODO fuzzy
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# add ~# versions
|
|
348
|
+
for k in list(ID2MODEL.keys()):
|
|
349
|
+
if k.endswith("/schema"):
|
|
350
|
+
ID2MODEL[k + "#"] = ID2MODEL[k]
|
|
351
|
+
|
|
352
|
+
def jsu_model():
|
|
353
|
+
|
|
354
|
+
from .convert import schema2model
|
|
355
|
+
|
|
356
|
+
ap = argparse.ArgumentParser()
|
|
357
|
+
ap_common(ap)
|
|
358
|
+
ap.add_argument("--id", action="store_true", default=False, help="enable $id lookup")
|
|
359
|
+
ap.add_argument("--no-id", dest="id", action="store_false", help="disable $id lookup")
|
|
360
|
+
ap.add_argument("--strict", action="store_true", default=True, help="reject doubtful schemas")
|
|
361
|
+
ap.add_argument("--loose", dest="strict", action="store_false", help="accept doubtful schemas")
|
|
362
|
+
ap.add_argument("schemas", nargs="*", help="schemas to process")
|
|
363
|
+
args = ap.parse_args()
|
|
364
|
+
|
|
365
|
+
log.setLevel(logging.DEBUG if args.debug else logging.WARNING if args.quiet else logging.INFO)
|
|
366
|
+
|
|
367
|
+
if args.version:
|
|
368
|
+
print(__version__)
|
|
369
|
+
sys.exit(0)
|
|
370
|
+
|
|
371
|
+
if not args.schemas:
|
|
372
|
+
args.schemas = ["-"]
|
|
373
|
+
|
|
374
|
+
errors = 0
|
|
375
|
+
|
|
376
|
+
for fn in args.schemas:
|
|
377
|
+
log.debug(f"considering: {fn}")
|
|
378
|
+
try:
|
|
379
|
+
schema = json.load(open(fn) if fn != "-" else sys.stdin)
|
|
380
|
+
model = None
|
|
381
|
+
if args.id and isinstance(schema, dict):
|
|
382
|
+
sid = (schema["$id"] if "$id" in schema else
|
|
383
|
+
schema["id"] if "id" in schema else
|
|
384
|
+
None)
|
|
385
|
+
if sid in ID2MODEL:
|
|
386
|
+
log.info(f"using predefined model for {sid}")
|
|
387
|
+
model = f"${ID2MODEL[sid][0 if args.strict else 1]}"
|
|
388
|
+
if model is None:
|
|
389
|
+
model = schema2model(schema, strict=args.strict)
|
|
390
|
+
except Exception as e:
|
|
391
|
+
log.error(e, exc_info=args.debug)
|
|
392
|
+
errors += 1
|
|
393
|
+
model = {"ERROR": str(e)}
|
|
394
|
+
print(json.dumps(model, sort_keys=args.sort_keys, indent=args.indent))
|
|
395
|
+
|
|
396
|
+
sys.exit(1 if errors else 0)
|