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/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)