wizard-work/beam/test_jsonl_validate.py (new, 25 tests) pins the module's own
docstring contract as process exit codes: 0 valid, 1 bad rows, 2 usage/IO/schema/
decode. wizard-work/beam/jsonl_validate.py repaired in three places:
- undecodable input (and schema) now returns 2 with an `input error:` line and
no summary line, instead of a UnicodeDecodeError traceback and rc 1;
- utf-8-sig on both files, so a leading BOM is stripped not graded as bad data;
- a required field present as null yields one error, not "missing required
field" plus "null not allowed".
Measured: the new test file against the shipped module (md5 f53b6288...) fails
6 of 25 (3 decode, 2 BOM, 1 null); against the repair, 25/25 pass. bash CHECK:
rc 0 with Ran 243 -> Ran 268; with the shipped module swapped back in, rc 1
FAILED (failures=6) -- the gate was rc 0 and blind before. No edit to CHECK:
the file is a unittest.TestCase, so the existing -p 'test_*.py' leg collects it.
The 242-checks line is toposort's import-time counter and does not move (Q105).
verify.py --repo . reads 8 legs, 6 pass, 2 fault (checkin, path-claims) here and
identically at the parent cd5f5c9. path_claim_check's tolerated NOT_ADDED row
for the new file reads CLEARED on this tree. Q105's 27 uncollected files / 131
tests remain open and untouched.
173 lines
6.3 KiB
Python
Executable File
173 lines
6.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""jsonl_validate.py — streaming JSONL schema validator.
|
|
|
|
Usage:
|
|
jsonl_validate.py SCHEMA.json INPUT.jsonl [--max-errors N]
|
|
|
|
SCHEMA is a small JSON document:
|
|
{
|
|
"required": ["id", "name"],
|
|
"fields": {
|
|
"id": "int",
|
|
"name": "str",
|
|
"tags": "list",
|
|
"meta": "object",
|
|
"score": "float",
|
|
"active": "bool",
|
|
"email": "str?"
|
|
}
|
|
}
|
|
|
|
Type names: str, int, float, bool, list, object, any. Append '?' for optional
|
|
(missing allowed). A field present with null is treated as missing for optional
|
|
fields and as an error for required fields -- one error per record per field, not
|
|
one for the absence and another for the null.
|
|
|
|
Input is read as UTF-8; a leading BOM is stripped, not reported as bad data.
|
|
|
|
Exit codes (pinned by test_jsonl_validate.py; a run that cannot read its input
|
|
returns 2 and prints NO summary line, so a partial count is never a verdict):
|
|
0 all records valid
|
|
1 one or more validation errors
|
|
2 usage / IO / schema / decode error -- including an input, or a schema,
|
|
that is not valid UTF-8 text
|
|
"""
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
|
|
TYPE_CHECKS = {
|
|
"str": lambda v: isinstance(v, str),
|
|
"int": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
|
"float": lambda v: (isinstance(v, (int, float)) and not isinstance(v, bool)),
|
|
"bool": lambda v: isinstance(v, bool),
|
|
"list": lambda v: isinstance(v, list),
|
|
"object": lambda v: isinstance(v, dict),
|
|
"any": lambda v: True,
|
|
}
|
|
|
|
|
|
def load_json(path: str) -> Any:
|
|
# utf-8-sig on both inputs: a BOM is an encoding marker, not bad data.
|
|
with open(path, "r", encoding="utf-8-sig") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def validate_schema(schema: Any) -> Tuple[bool, str]:
|
|
if not isinstance(schema, dict):
|
|
return False, "schema must be an object"
|
|
if "required" in schema and not (isinstance(schema["required"], list)
|
|
and all(isinstance(x, str) for x in schema["required"])):
|
|
return False, "'required' must be a list of strings"
|
|
if "fields" in schema and not isinstance(schema["fields"], dict):
|
|
return False, "'fields' must be an object"
|
|
fields = schema.get("fields", {})
|
|
for name, typ in fields.items():
|
|
if not isinstance(name, str):
|
|
return False, "field names must be strings"
|
|
base = typ[:-1] if isinstance(typ, str) and typ.endswith("?") else typ
|
|
if base not in TYPE_CHECKS:
|
|
return False, f"unknown type '{typ}' for field '{name}'"
|
|
return True, ""
|
|
|
|
|
|
def check_value(typ: str, value: Any) -> bool:
|
|
optional = typ.endswith("?")
|
|
base = typ[:-1] if optional else typ
|
|
if value is None and optional:
|
|
return True
|
|
return TYPE_CHECKS[base](value)
|
|
|
|
|
|
def validate_record(rec: Any, schema: dict) -> List[str]:
|
|
errs: List[str] = []
|
|
if not isinstance(rec, dict):
|
|
return ["record is not an object"]
|
|
required = set(schema.get("required", []))
|
|
fields = schema.get("fields", {})
|
|
for r in required:
|
|
if r not in rec or rec.get(r) is None:
|
|
errs.append(f"missing required field '{r}'")
|
|
for name, typ in fields.items():
|
|
if name in rec and rec[name] is not None:
|
|
if not check_value(typ, rec[name]):
|
|
errs.append(f"field '{name}': expected {typ}, got {type(rec[name]).__name__}")
|
|
elif name in rec and rec[name] is None:
|
|
# A required field present as null already produced its error above
|
|
# ("missing required field"); do not count the same fact twice.
|
|
optional = typ.endswith("?")
|
|
if not optional and name not in required:
|
|
errs.append(f"field '{name}': null not allowed")
|
|
# unknown fields? warn-level: not an error, but collect as info? keep strict-ish: allow extras
|
|
return errs
|
|
|
|
|
|
def main(argv: List[str]) -> int:
|
|
ap = argparse.ArgumentParser(description="Validate a JSONL file against a schema.")
|
|
ap.add_argument("schema")
|
|
ap.add_argument("input")
|
|
ap.add_argument("--max-errors", type=int, default=50)
|
|
args = ap.parse_args(argv)
|
|
|
|
try:
|
|
schema = load_json(args.schema)
|
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
print(f"schema error: {e}", file=sys.stderr)
|
|
return 2
|
|
ok, msg = validate_schema(schema)
|
|
if not ok:
|
|
print(f"schema error: {msg}", file=sys.stderr)
|
|
return 2
|
|
|
|
total = 0
|
|
bad = 0
|
|
err_count = 0
|
|
stopped = False
|
|
try:
|
|
# utf-8-sig: a leading BOM is an encoding marker, not invalid data, so it
|
|
# is stripped rather than graded as a bad record on line 1.
|
|
with open(args.input, "r", encoding="utf-8-sig") as f:
|
|
for lineno, line in enumerate(f, 1):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
total += 1
|
|
try:
|
|
rec = json.loads(line)
|
|
except json.JSONDecodeError as e:
|
|
bad += 1
|
|
err_count += 1
|
|
print(f"line {lineno}: invalid JSON: {e}")
|
|
if err_count >= args.max_errors:
|
|
stopped = True
|
|
break
|
|
continue
|
|
errs = validate_record(rec, schema)
|
|
if errs:
|
|
bad += 1
|
|
for e in errs:
|
|
err_count += 1
|
|
print(f"line {lineno}: {e}")
|
|
if err_count >= args.max_errors:
|
|
stopped = True
|
|
break
|
|
if stopped:
|
|
break
|
|
except UnicodeDecodeError as e:
|
|
# Undecodable bytes are an IO/encoding failure, not a validation error:
|
|
# rc 2, no traceback, and no summary line that a caller could mistake
|
|
# for a verdict on the rows it never reached.
|
|
print(f"input error: not valid UTF-8 text ({e.reason})", file=sys.stderr)
|
|
return 2
|
|
except OSError as e:
|
|
print(f"input error: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
print(f"checked {total} records, {bad} invalid" + (" (truncated)" if stopped else ""))
|
|
return 1 if bad else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:])) |