X-S5-06 Delivery coverage check

Field Value
Purpose Flag each stem a finished bank actually holds that is not assigned to any deliverable kind in a delivery manifest, and print a summary line.
Usage python3 scripts/s5/delivery_coverage_check.py –help In a shell: python3 scripts/s5/delivery_coverage_check.py MANIFEST.json STEMS.json
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs A delivery-manifest JSON file: a JSON object mapping a stem id to a list of deliverable-kind strings, such as “bank”, “chapter_quiz”, “mock_exam” or “practice_test”. A stem id absent from the manifest and a stem id present with an empty list both count as zero deliverable kinds. A stem JSON file (see format_rules_check.py): a list of objects, each with a stem_id.
Outputs One line per gap, then a summary line, all printed to standard output.
Used in Stage 5 delivery formats and lessons
Source scripts/s5/delivery_coverage_check.py

Source code

"""
ID: X-S5-06
Title: Delivery coverage check
Stage: S5
Purpose: Flag each stem a finished bank actually holds that is not
    assigned to any deliverable kind in a delivery manifest, and print
    a summary line.
Usage: python3 scripts/s5/delivery_coverage_check.py --help
    In a shell: python3 scripts/s5/delivery_coverage_check.py
    MANIFEST.json STEMS.json
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: A delivery-manifest JSON file: a JSON object mapping a stem id
    to a list of deliverable-kind strings, such as "bank",
    "chapter_quiz", "mock_exam" or "practice_test". A stem id absent
    from the manifest and a stem id present with an empty list both
    count as zero deliverable kinds. A stem JSON file (see
    format_rules_check.py): a list of objects, each with a stem_id.
Outputs: One line per gap, then a summary line, all printed to
    standard output.

This script checks one criterion only: whether each stem the bank
actually holds is assigned to at least one deliverable kind. It does
not check a stem's own format, an answer key, or a bank's own
difficulty or domain composition; other scripts in this stage already
do that. It also does not check the other direction: a manifest entry
naming a stem id no longer in the bank is not reported here. A stem
entry that is not a JSON object, or has no stem_id, is skipped rather
than reported, and so is a stem entry marked with a true
broken_on_purpose field, the same skip a normal run of any script in
this stage gives that kind of entry. A manifest value that is not a
JSON list is treated as zero deliverable kinds rather than raising an
error.

A gap is information a person reads, not a failure of this script, so
a gap never changes the exit code. The exit code is 0 unless an input
file cannot be read, or is not the JSON shape this script expects.
"""

import argparse
import json
import sys
from pathlib import Path
from typing import Any

sys.dont_write_bytecode = True
if sys.version_info < (3, 10):
    print(
        "delivery_coverage_check.py: this script needs Python 3.10 or newer, "
        f"but this is {sys.version_info.major}.{sys.version_info.minor}. "
        "Run it with a newer python3.",
        file=sys.stderr,
    )
    sys.exit(2)

MAX_BYTES = 5_000_000


def load_json(path: Path) -> Any:
    """Read and parse a JSON file; never follows a symlink."""
    if path.is_symlink():
        raise OSError(f"refusing to read a symlink: {path}")
    size = path.stat().st_size
    if size > MAX_BYTES:
        raise ValueError(f"{path} is over {MAX_BYTES} bytes; skipping")
    text = path.read_text(encoding="utf-8", errors="replace")
    return json.loads(text)


def find_stem_ids(data: Any) -> list[str]:
    """Return every stem id STEMS.json actually holds, in file order.

    This walks the same stem-list shape format_rules_check.py reads,
    but does not repeat its checks here: a stem entry that is not a
    JSON object, or has no stem_id, is skipped rather than reported.
    A stem entry marked with a true broken_on_purpose field is also
    skipped, the same way a normal run of any script in this stage
    skips that kind of entry; it exists only to demonstrate another
    script's own error paths, on a temporary copy, and is never part
    of the real bank.
    """
    if not isinstance(data, list):
        raise ValueError("the stem file is not a JSON list")
    found: list[str] = []
    for entry in data:
        if not isinstance(entry, dict) or entry.get("broken_on_purpose"):
            continue
        stem_id = entry.get("stem_id")
        if isinstance(stem_id, str) and stem_id:
            found.append(stem_id)
    return found


def find_gaps(stem_ids: list[str], manifest: Any) -> list[str]:
    """Return one 'gap: ...' line for every stem id assigned zero kinds.

    A stem id absent from the manifest and a stem id present with an
    empty (or malformed) list both count as zero deliverable kinds.
    """
    if not isinstance(manifest, dict):
        raise ValueError("the manifest file is not a JSON object")
    lines: list[str] = []
    for stem_id in stem_ids:
        kinds = manifest.get(stem_id)
        count = len(kinds) if isinstance(kinds, list) else 0
        if count == 0:
            lines.append(
                f'gap: "{stem_id}" is in the bank but not assigned to any '
                "deliverable"
            )
    return lines


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        prog="delivery_coverage_check.py",
        description=(
            "Flag each stem in a finished bank with no deliverable-kind "
            "assignment, and print a summary. Never fails on its own; "
            "writes no files."
        ),
    )
    parser.add_argument(
        "manifest", metavar="MANIFEST", help="path to the delivery-manifest JSON"
    )
    parser.add_argument("stems", metavar="STEMS", help="path to the stem JSON")
    return parser


def main(argv: list[str] | None = None) -> int:
    """Command line entry point; prints the report, returns an exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        manifest = load_json(Path(args.manifest))
        stems = load_json(Path(args.stems))
        stem_ids = find_stem_ids(stems)
        gaps = find_gaps(stem_ids, manifest)
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"delivery_coverage_check.py: error: {exc}", file=sys.stderr)
        return 2
    for line in gaps:
        print(line)
    print(f"stems={len(stem_ids)} unassigned={len(gaps)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

To the extent possible under law, copyright and related rights in this work are waived under CC0 1.0 Universal.

This site uses Just the Docs, a documentation theme for Jekyll.