restincode/scripts/validate_people.py
mauvehed 20690fb188
ci(validate): skip convention checks on schema-invalid data
Schema errors were reported but the cross-file checks still walked the
data as if it were well-formed, so a null list entry or a null item in
socialmedialinks crashed with an AttributeError and hid every other
error. Treat schema validation as a gate: report and skip the file.

Also skip the 'not listed in peoplelist.json' check when peoplelist.json
itself fails the schema, so the root cause is not buried.
2026-09-05 14:39:57 -05:00

227 lines
8.4 KiB
Python
Executable file

#!/usr/bin/env python3
"""Validate memorial data files.
Checks people/*.json against schema/person.schema.json and enforces the
cross-file conventions the site relies on:
- every peoplelist.json entry points at an existing people/<filename>.json
- every people/*.json (except _template.json) is listed exactly once
- filenames are lowercase alphanumeric
- local image paths exist under images/
- link fields are http(s) URLs with no surrounding whitespace
- text-only fields contain no HTML (maintext is the one HTML field)
people/_template.json is scaffolding with intentional blanks and is skipped.
Errors exit non-zero. Warnings are printed but do not fail the run.
Usage: scripts/validate_people.py [--root DIR]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
import jsonschema
FILENAME_RE = re.compile(r"^[a-z0-9]+$")
HTTP_RE = re.compile(r"^https?://\S+$")
HTML_RE = re.compile(r"<[a-zA-Z/!]|&[a-zA-Z#][a-zA-Z0-9]*;")
# Filenames that predate the lowercase-alphanumeric rule. Renaming them would
# change public memorial URLs, so they are allowed as-is. Do not add to this
# list for new entries.
LEGACY_FILENAMES = {"karenKrystalia", "obsèquesdepaolo"}
TEXT_FIELDS = ("firstname", "lastname", "handle", "affiliations", "birth", "death")
PEOPLELIST_SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": ["people"],
"properties": {
"people": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["displayname", "filename"],
"properties": {
"displayname": {"type": "string", "minLength": 1},
"filename": {"type": "string"},
},
},
}
},
}
class Report:
def __init__(self) -> None:
self.errors: list[str] = []
self.warnings: list[str] = []
def error(self, where: str, msg: str) -> None:
self.errors.append(f"{where}: {msg}")
def warn(self, where: str, msg: str) -> None:
self.warnings.append(f"{where}: {msg}")
def load_json(path: Path, report: Report):
try:
with path.open(encoding="utf-8") as fh:
return json.load(fh)
except (OSError, ValueError) as exc:
report.error(f"{path.parent.name}/{path.name}", f"cannot parse JSON: {exc}")
return None
def check_schema(path: Path, data, validator: jsonschema.Validator, report: Report) -> bool:
"""Report schema errors. Returns True when the data is valid.
The convention checks below assume schema-valid structure (arrays of
objects with the expected keys), so callers skip them on False.
"""
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path))
for err in errors:
loc = "/".join(str(p) for p in err.absolute_path) or "(root)"
report.error(str(path), f"schema: {loc}: {err.message}")
return not errors
def check_text(where: str, field: str, value, report: Report) -> None:
if isinstance(value, str) and HTML_RE.search(value):
report.error(where, f"{field}: HTML is not allowed in this field: {value!r}")
def check_link(where: str, field: str, value, report: Report) -> None:
if not isinstance(value, str) or value == "":
return
if value != value.strip():
report.error(where, f"{field}: URL has surrounding whitespace: {value!r}")
elif not HTTP_RE.match(value):
report.error(where, f"{field}: not an http(s) URL: {value!r}")
def list_images(root: Path) -> set[str]:
"""Site-relative paths of every file under images/, e.g. 'images/Foo.jpg'.
Compared as exact strings so a case mismatch fails here the same way it
fails on GitHub Pages, even on a case-insensitive local filesystem.
"""
images_dir = root / "images"
return {p.relative_to(root).as_posix() for p in images_dir.rglob("*") if p.is_file()}
def check_image(
where: str, field: str, value, images: set[str], report: Report
) -> None:
if not isinstance(value, str) or value == "":
return
if value.startswith("/images/"):
if value.lstrip("/") not in images:
report.error(where, f"{field}: image not found (case-sensitive): {value}")
else:
check_link(where, field, value, report)
def check_person(
path: Path, data: dict, root: Path, images: set[str], report: Report
) -> None:
where = str(path.relative_to(root))
for field in TEXT_FIELDS:
check_text(where, field, data.get(field), report)
check_link(where, "obituary", data.get("obituary"), report)
check_image(where, "mainimage", data.get("mainimage"), images, report)
for i, item in enumerate(data.get("socialmedialinks") or []):
check_text(where, f"socialmedialinks[{i}].sitename", item.get("sitename"), report)
check_link(where, f"socialmedialinks[{i}].siteurl", item.get("siteurl"), report)
for i, item in enumerate(data.get("references") or []):
check_text(where, f"references[{i}].title", item.get("title"), report)
check_link(where, f"references[{i}].url", item.get("url"), report)
for i, item in enumerate(data.get("contributions") or []):
check_text(where, f"contributions[{i}].title", item.get("title"), report)
check_text(where, f"contributions[{i}].description", item.get("description"), report)
check_link(where, f"contributions[{i}].url", item.get("url"), report)
for i, item in enumerate(data.get("gallery") or []):
check_text(where, f"gallery[{i}].title", item.get("title"), report)
check_text(where, f"gallery[{i}].caption", item.get("caption"), report)
check_image(where, f"gallery[{i}].url", item.get("url"), images, report)
if not item.get("title"):
report.warn(where, f"gallery[{i}]: no title (used as the image alt text)")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
args = parser.parse_args()
root: Path = args.root
report = Report()
schema = load_json(root / "schema" / "person.schema.json", report)
if schema is None:
return finish(report)
validator_cls = jsonschema.validators.validator_for(schema)
validator_cls.check_schema(schema)
person_validator = validator_cls(schema)
list_validator = jsonschema.Draft202012Validator(PEOPLELIST_SCHEMA)
people_dir = root / "people"
images = list_images(root)
files = {p.stem: p for p in sorted(people_dir.glob("*.json")) if p.stem != "_template"}
peoplelist_path = root / "peoplelist.json"
peoplelist = load_json(peoplelist_path, report)
listed: dict[str, int] = {}
peoplelist_ok = peoplelist is not None and check_schema(
peoplelist_path.relative_to(root), peoplelist, list_validator, report
)
if peoplelist_ok:
for entry in peoplelist["people"]:
name = entry["filename"]
if name == "":
continue # intentional no-link card
listed[name] = listed.get(name, 0) + 1
if name not in files:
report.error("peoplelist.json", f"'{name}' has no people/{name}.json")
for name, count in listed.items():
if count > 1:
report.error("peoplelist.json", f"'{name}' is listed {count} times")
for stem, path in files.items():
where = str(path.relative_to(root))
if peoplelist_ok and stem not in listed:
report.error(where, "not listed in peoplelist.json")
if not FILENAME_RE.match(stem) and stem not in LEGACY_FILENAMES:
report.error(where, "filename must be lowercase alphanumeric (a-z, 0-9)")
data = load_json(path, report)
if data is None:
continue
if check_schema(path.relative_to(root), data, person_validator, report):
check_person(path, data, root, images, report)
print(f"checked {len(files)} memorial files, {len(listed)} peoplelist entries")
return finish(report)
def finish(report: Report) -> int:
for msg in report.warnings:
print(f"WARNING: {msg}")
for msg in report.errors:
print(f"ERROR: {msg}")
print(f"{len(report.errors)} error(s), {len(report.warnings)} warning(s)")
return 1 if report.errors else 0
if __name__ == "__main__":
sys.exit(main())