X-CA-01 Schedule check

Field Value
Purpose Check a study-schedule JSON file for the errors and warnings described below: that every chapter’s reading and active-learning minutes sum to its own stated total, and that no lower importance-tier chapter is given strictly more total time than a higher-tier chapter.
Usage python3 scripts/ca/schedule_check.py –help In a shell: python3 scripts/ca/schedule_check.py SCHEDULE.json
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs A schedule JSON file: a list of chapter objects, each with chapter (a number), importance_tier (“high”, “medium” or “low”), reading_minutes, active_minutes and total_minutes.
Outputs One line per error or warning, then a summary line, all printed to standard output.
Used in Certification alignment
Source scripts/ca/schedule_check.py

Source code

"""
ID: X-CA-01
Title: Schedule check
Stage: CA
Purpose: Check a study-schedule JSON file for the errors and warnings
    described below: that every chapter's reading and active-learning
    minutes sum to its own stated total, and that no lower
    importance-tier chapter is given strictly more total time than a
    higher-tier chapter.
Usage: python3 scripts/ca/schedule_check.py --help
    In a shell: python3 scripts/ca/schedule_check.py SCHEDULE.json
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: A schedule JSON file: a list of chapter objects, each with
    chapter (a number), importance_tier ("high", "medium" or "low"),
    reading_minutes, active_minutes and total_minutes.
Outputs: One line per error or warning, then a summary line, all
    printed to standard output.

This script checks arithmetic and tier ordering only. It never judges
whether a chapter's own importance tier is the right one, or whether
its time allocation is realistic for a real learner.

Errors (exit 1): a chapter whose reading_minutes plus active_minutes
does not equal its own total_minutes.

Warnings (exit 0, never change the exit code): a chapter at a lower
importance tier with a strictly higher total_minutes than a chapter at
a higher tier. This is a warning, not an error, because a small,
invented schedule will not always order cleanly on its own.

A malformed schedule (not a JSON list; an entry that is not a JSON
object; a missing field; a chapter, reading_minutes, active_minutes or
total_minutes that is not a number; an importance_tier outside high,
medium and low) is a usage or input error (exit 2), not a finding,
since it means the file does not match the schema at all.
"""

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

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

TIER_ORDER = {"low": 0, "medium": 1, "high": 2}
REQUIRED_FIELDS = (
    "importance_tier",
    "reading_minutes",
    "active_minutes",
    "total_minutes",
)
MAX_BYTES = 5_000_000


def _is_number(value: Any) -> TypeGuard[float]:
    """True for an int or float, but not a bool (bool is a subclass of int)."""
    return isinstance(value, (int, float)) and not isinstance(value, bool)


def load_schedule(path: Path) -> Any:
    """Read and parse the schedule 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 check_schedule(data: Any) -> tuple[list[str], list[str], int]:
    """Return (errors, warnings, chapter_count) for a parsed schedule."""
    if not isinstance(data, list) or not data:
        raise ValueError("the schedule is not a non-empty JSON list")

    chapters: list[dict[str, Any]] = []
    for position, entry in enumerate(data, start=1):
        if not isinstance(entry, dict):
            raise ValueError(f"entry #{position} is not a JSON object")
        if "chapter" not in entry or not _is_number(entry["chapter"]):
            raise ValueError(f"entry #{position} has no numeric 'chapter'")
        chapter = entry["chapter"]
        for field_name in REQUIRED_FIELDS:
            if field_name not in entry:
                raise ValueError(f"chapter {chapter} has no '{field_name}'")
        tier = entry["importance_tier"]
        if tier not in TIER_ORDER:
            raise ValueError(
                f"chapter {chapter} has importance_tier {tier!r}, "
                "not one of high, medium, low"
            )
        for field_name in ("reading_minutes", "active_minutes", "total_minutes"):
            if not _is_number(entry[field_name]):
                raise ValueError(f"chapter {chapter} has a non-numeric '{field_name}'")
        chapters.append(entry)

    errors: list[str] = []
    for entry in chapters:
        chapter = entry["chapter"]
        reading = entry["reading_minutes"]
        active = entry["active_minutes"]
        total = entry["total_minutes"]
        computed = reading + active
        if computed != total:
            errors.append(
                f"sum-mismatch: chapter {chapter} reading_minutes ({reading}) "
                f"+ active_minutes ({active}) = {computed}, not total_minutes "
                f"({total})"
            )

    warnings: list[str] = []
    for lower in chapters:
        lower_rank = TIER_ORDER[lower["importance_tier"]]
        for higher in chapters:
            if lower is higher:
                continue
            higher_rank = TIER_ORDER[higher["importance_tier"]]
            if lower_rank >= higher_rank:
                continue
            if lower["total_minutes"] > higher["total_minutes"]:
                warnings.append(
                    f"tier-order: chapter {lower['chapter']} "
                    f"({lower['importance_tier']}, total "
                    f"{lower['total_minutes']}) exceeds chapter "
                    f"{higher['chapter']} ({higher['importance_tier']}, "
                    f"total {higher['total_minutes']})"
                )

    return errors, warnings, len(chapters)


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        prog="schedule_check.py",
        description=(
            "Check a study-schedule JSON file's per-chapter time arithmetic "
            "and importance-tier ordering. Writes no files."
        ),
    )
    parser.add_argument(
        "schedule", metavar="SCHEDULE", help="path to the schedule JSON file"
    )
    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:
        data = load_schedule(Path(args.schedule))
        errors, warnings, chapter_count = check_schedule(data)
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"schedule_check.py: error: {exc}", file=sys.stderr)
        return 2
    for message in errors:
        print(f"error {message}")
    for message in warnings:
        print(f"warning {message}")
    print(f"chapters={chapter_count} errors={len(errors)} warnings={len(warnings)}")
    return 1 if errors else 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.