#!/usr/bin/env python3 """Validate packages, or run the correction gate on proposals against a package. Non-normative tooling. Usage: python3 tools/validate.py PACKAGE [PACKAGE ...] python3 tools/validate.py --correction PROPOSALS PACKAGE A package is a folder, or a zipped package (`.sct.zip`), which is unpacked under the rules of spec/12 §12.2 before it is checked. Each is checked as spec/22 §22.2 describes, and each failure printed as `package: where: rule: message`. With --correction, PROPOSALS is a JSON file holding one proposed correction, or a JSON Lines file of several applied in order. Each runs through the gate (spec/11 §11.4) against the package's current state, and the record it would append to corrections.jsonl is printed, until one is refused. Nothing is written. Exit status: 0 when everything is valid, 1 when something is refused, 2 for a usage error. """ import argparse import json import os import sys import tempfile ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) import tools # noqa: E402,F401 checks the runtime and puts src/ first on the path, before paperspec is imported from paperspec import corrections, jcs, package # noqa: E402 from paperspec.schema import Store # noqa: E402 from paperspec.tree import Refusal # noqa: E402 def read_proposals(path): with open(path, encoding="utf-8") as f: text = f.read() try: return [json.loads(text)] except json.JSONDecodeError: return [json.loads(ln) for ln in text.splitlines() if ln.strip()] def validate_one(path, store, limit): """The validation report of a package folder or a zipped package.""" if package.is_zip(path): return package.validate_zip(path, store, limit=limit) return package.validate(path, store) def main(): lines = __doc__.splitlines() ap = argparse.ArgumentParser(description=lines[0], epilog="\n".join(lines[5:]), formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--correction", metavar="PROPOSALS", help="a JSON or JSON Lines file of proposed corrections to run through the gate") ap.add_argument("--max-size", metavar="BYTES", type=int, default=package.ZIP_LIMIT, help="refuse a zipped package that expands past this many bytes (default %d)" % package.ZIP_LIMIT) ap.add_argument("packages", nargs="+", metavar="PACKAGE", help="package folders or zipped packages") a = ap.parse_args() if a.correction and len(a.packages) != 1: ap.error("--correction checks proposals against exactly one package") store, bad = Store(os.path.join(ROOT, "schemas")), False for p in a.packages: for e in validate_one(p, store, a.max_size).errors: print("%s: %s: %s: %s" % (p, e["where"], e["rule"], e["message"]), file=sys.stderr) bad = True if bad: return 1 if not a.correction: for p in a.packages: print("%s: valid" % p) return 0 pkg = a.packages[0] with tempfile.TemporaryDirectory() as tmp: if package.is_zip(pkg): pkg = package.read_zip(pkg, tmp, a.max_size) state = package.read_state(pkg) if any(n.get("withheld") for n in state.nodes): print("%s: doc.policy.withheld: a package that withholds its text takes no corrections" % a.packages[0], file=sys.stderr) return 1 for i, proposal in enumerate(read_proposals(a.correction), 1): try: state, record = corrections.gate(state, proposal, store) except Refusal as e: where = "%s:%d" % (a.correction, i) + (" " + e.node if e.node else "") print("%s: %s: %s" % (where, e.rule, e.message), file=sys.stderr) return 1 print(jcs.dumps(record)) return 0 if __name__ == "__main__": sys.exit(main())