Files
libretro/scripts/validate_schemas.py
T

360 lines
13 KiB
Python

#!/usr/bin/env python3
"""Validate source and generated RetroBIOS data contracts."""
from __future__ import annotations
import argparse
import json
import re
import sys
import zipfile
from pathlib import Path, PurePosixPath
import yaml
from common import yaml_load
from jsonschema import Draft202012Validator, FormatChecker
ROOT = Path(__file__).resolve().parent.parent
SCHEMAS = ROOT / "schemas"
def _load_json(path: Path) -> object:
with path.open(encoding="utf-8") as handle:
return json.load(handle)
def _validator(name: str) -> Draft202012Validator:
schema = _load_json(SCHEMAS / name)
Draft202012Validator.check_schema(schema)
return Draft202012Validator(schema, format_checker=FormatChecker())
def _errors(validator: Draft202012Validator, data: object, label: str) -> list[str]:
out: list[str] = []
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
location = "/".join(str(part) for part in error.absolute_path) or "<root>"
out.append(f"{label}:{location}: {error.message}")
return out
def _validate_yaml_directory(
directory: Path,
schema_name: str,
*,
skip_private: bool = False,
) -> list[str]:
validator = _validator(schema_name)
out: list[str] = []
for path in sorted(directory.glob("*.yml")):
if skip_private and path.name.startswith("_"):
continue
try:
with path.open(encoding="utf-8") as handle:
data = yaml_load(handle)
except (OSError, yaml.YAMLError) as exc:
out.append(f"{path.relative_to(ROOT)}: {exc}")
continue
out.extend(_errors(validator, data, str(path.relative_to(ROOT))))
out.extend(_unreachable_citations(path, data))
return out
def _validate_json_files(paths: list[Path], schema_name: str) -> list[str]:
validator = _validator(schema_name)
out: list[str] = []
for path in paths:
try:
data = _load_json(path)
except (OSError, json.JSONDecodeError) as exc:
out.append(f"{path.relative_to(ROOT)}: {exc}")
continue
out.extend(_errors(validator, data, str(path.relative_to(ROOT))))
return out
def _validate_pack_manifests(dist: Path) -> list[str]:
"""Validate the integrity manifest each generated pack carries.
generate_pack.py writes manifest.json inside the archive, not beside it,
so a filesystem glob over dist/ matches nothing and silently validates
zero documents.
Reading a pack while a build is writing it reports "File is not a zip
file" about an archive that is merely half-written, so this takes the same
shared lock --verify-packs does. A build holding the exclusive lock means
the packs on disk are mid-flight and there is nothing stable to validate.
"""
if not dist.is_dir():
return []
sys.path.insert(0, str(ROOT / "scripts"))
from common import ArtifactLockBusy, artifact_lock
try:
with artifact_lock(str(dist), exclusive=False):
return _scan_pack_manifests(dist)
except ArtifactLockBusy:
print(
f"note: {dist.name} is being written; skipping pack manifests",
file=sys.stderr,
)
return []
def _scan_pack_manifests(dist: Path) -> list[str]:
validator = _validator("pack-manifest.schema.json")
def _label(path: Path) -> str:
resolved = path.resolve()
try:
return str(resolved.relative_to(ROOT))
except ValueError:
return str(resolved)
out: list[str] = []
for archive in sorted(dist.glob("*.zip")):
try:
with zipfile.ZipFile(archive) as handle:
members = [
name
for name in handle.namelist()
if PurePosixPath(name).name == "manifest.json"
]
for member in members:
label = f"{_label(archive)}:{member}"
try:
document = json.loads(handle.read(member).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
out.append(f"{label}: {exc}")
continue
out.extend(_errors(validator, document, label))
except (OSError, zipfile.BadZipFile) as exc:
out.append(f"{_label(archive)}: {exc}")
return out
_UNREACHABLE_REF = re.compile(
r"(?:^|[\s,;(])(?:tmp/|/|[A-Za-z]:[\\/]|\.{1,2}/)[\w.\\/+-]*[\w+-]:\d+(?:-\d+)?"
)
def _unreachable_citations(path: Path, document: object) -> list[str]:
"""Refs naming a place no repository can hold.
A citation is read against a revision of a declared repository, so a
scratch directory, an absolute path or one climbing out of the tree can
never resolve and never will. kenji-nx carried tmp/es-de/ANDROID.md:470,
a path from whoever profiled it, which profile_sync could only report
missing forever.
"""
out: list[str] = []
stack: list[object] = [document]
while stack:
node = stack.pop()
if isinstance(node, dict):
stack.extend(node.values())
elif isinstance(node, list):
stack.extend(node)
elif isinstance(node, str):
for match in _UNREACHABLE_REF.finditer(node):
out.append(
f"{path.relative_to(ROOT)}: citation names a path outside "
f"any repository: {match.group().strip(' ,;(')}"
)
return out
def _unknown_data_dir_refs() -> list[str]:
"""data_directories refs the registry does not define.
A ref nobody registers is never fetched: yaps2 named yaps2-resources and
every run printed "not cached" while refresh_data_dirs had nothing to
refresh.
"""
registry_path = ROOT / "platforms" / "_data_dirs.yml"
with registry_path.open(encoding="utf-8") as handle:
registered = set((yaml_load(handle) or {}).get("data_directories", {}))
out: list[str] = []
for directory in (ROOT / "emulators", ROOT / "platforms"):
for path in sorted(directory.glob("*.yml")):
if path.name.startswith("_"):
continue
with path.open(encoding="utf-8") as handle:
document = yaml_load(handle) or {}
systems = document.get("systems")
holders = [document, *(systems.values() if isinstance(systems, dict) else [])]
for holder in holders:
if not isinstance(holder, dict):
continue
for entry in holder.get("data_directories") or []:
ref = entry.get("ref") if isinstance(entry, dict) else None
if ref and ref not in registered:
out.append(
f"{path.relative_to(ROOT)}: data directory {ref!r} "
f"is not in platforms/_data_dirs.yml"
)
return out
def _semantic_envelope_checks(path: Path, document: dict) -> list[str]:
if document.get("count") != len(document.get("items", [])):
return [f"{path.relative_to(ROOT)}: count does not equal len(items)"]
return []
def _semantic_database_checks(database: dict) -> list[str]:
out: list[str] = []
files = database.get("files", {})
if database.get("total_files") != len(files):
out.append("database.json: total_files does not equal len(files)")
if database.get("total_size") != sum(entry.get("size", 0) for entry in files.values()):
out.append("database.json: total_size does not equal the file-size sum")
# One path holds one content. Two entries naming the same path means one
# of them declares a hash the file at that path does not have.
owners: dict[str, str] = {}
for sha1, entry in files.items():
if entry.get("sha1") != sha1:
out.append(f"database.json: files/{sha1}: key and sha1 differ")
path = entry.get("path", "")
if not path:
continue
first = owners.setdefault(path, sha1)
if first != sha1:
out.append(
f"database.json: {path} is claimed by {first} and {sha1}"
)
return out
def _semantic_install_checks(path: Path, manifest: dict) -> list[str]:
out: list[str] = []
files = manifest.get("files", [])
if manifest.get("total_files") != len(files):
out.append(f"{path.relative_to(ROOT)}: total_files does not equal len(files)")
if manifest.get("total_size") != sum(entry.get("size", 0) for entry in files):
out.append(f"{path.relative_to(ROOT)}: total_size does not equal the file-size sum")
omitted = manifest.get("omitted_files", [])
if manifest.get("total_omitted") != len(omitted):
out.append(
f"{path.relative_to(ROOT)}: total_omitted does not equal "
"len(omitted_files)"
)
destinations = [entry.get("dest") for entry in files]
if len(destinations) != len(set(destinations)):
out.append(f"{path.relative_to(ROOT)}: duplicate destinations")
omitted_destinations = [entry.get("dest") for entry in omitted]
if len(omitted_destinations) != len(set(omitted_destinations)):
out.append(f"{path.relative_to(ROOT)}: duplicate omitted destinations")
overlap = set(destinations) & set(omitted_destinations)
if overlap:
out.append(
f"{path.relative_to(ROOT)}: destinations are both downloadable and omitted"
)
return out
def unchecked_formats() -> list[str]:
"""Formats the schemas declare that this jsonschema install cannot check.
date-time and uri need the format extras; without them the ten format
constraints of the published schemas were accepted unread.
"""
used = {
match
for path in SCHEMAS.glob("*.json")
for match in re.findall(r'"format"\s*:\s*"([^"]+)"', path.read_text(encoding="utf-8"))
}
return sorted(used - set(FormatChecker().checkers))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--source-only",
action="store_true",
help="validate emulator/platform YAML only",
)
args = parser.parse_args()
unchecked = unchecked_formats()
if unchecked:
# jsonschema skips a format it has no checker for, silently.
print(
f"ERROR no checker for format(s) {', '.join(unchecked)}: "
'install "jsonschema[format-nongpl]"',
file=sys.stderr,
)
return 1
errors: list[str] = []
errors.extend(
_validate_yaml_directory(ROOT / "emulators", "emulator.schema.json")
)
errors.extend(
_validate_yaml_directory(
ROOT / "platforms", "platform.schema.json", skip_private=True
)
)
errors.extend(_unknown_data_dir_refs())
if not args.source_only:
database_path = ROOT / "database.json"
errors.extend(_validate_json_files([database_path], "database.schema.json"))
if database_path.exists():
errors.extend(_semantic_database_checks(_load_json(database_path)))
install_paths = sorted((ROOT / "install").glob("*.json"))
errors.extend(
_validate_json_files(install_paths, "install-manifest.schema.json")
)
for path in install_paths:
errors.extend(_semantic_install_checks(path, _load_json(path)))
target_paths = sorted((ROOT / "install" / "targets").glob("*.json"))
errors.extend(
_validate_json_files(target_paths, "target-manifest.schema.json")
)
release_path = ROOT / "release.json"
if release_path.exists():
errors.extend(_validate_json_files([release_path], "release.schema.json"))
stats_path = ROOT / "docs" / "stats.json"
if stats_path.exists():
errors.extend(_validate_json_files([stats_path], "stats.schema.json"))
api_dir = ROOT / "docs" / "api" / "v1"
api_database = api_dir / "database.json"
if api_database.exists():
errors.extend(
_validate_json_files([api_database], "database.schema.json")
)
api_stats = api_dir / "stats.json"
if api_stats.exists():
errors.extend(_validate_json_files([api_stats], "stats.schema.json"))
envelope_paths = [
api_dir / name
for name in ("catalog.json", "platforms.json", "emulators.json", "gaps.json")
if (api_dir / name).exists()
]
errors.extend(
_validate_json_files(envelope_paths, "site-api-envelope.schema.json")
)
for path in envelope_paths:
errors.extend(_semantic_envelope_checks(path, _load_json(path)))
errors.extend(_validate_pack_manifests(ROOT / "dist"))
if errors:
for error in errors:
print(f"ERROR {error}")
print(f"{len(errors)} schema or semantic error(s)")
return 1
print("All RetroBIOS schemas and semantic invariants are valid.")
return 0
if __name__ == "__main__":
sys.exit(main())