X-DL-02 Volume manifest check

Field Value
Purpose Check a proposed chapter-to-volume manifest for an off-convention chapter id, a section id that does not reset at its own chapter’s boundary, and a volume whose chapters are not in numeral-aware order.
Usage python3 scripts/dl/volume_manifest_check.py –help In a shell: python3 scripts/dl/volume_manifest_check.py MANIFEST.json
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs A volume-manifest JSON file: a top-level “volumes” list. Each volume is a JSON object with a “volume” number and a “chapters” list. Each chapter is a JSON object with an “id” string (the expected “<part>.<chapter>” pattern, such as “3.9”) and a “sections” list holding its own section ids.
Outputs One line per finding, then a summary line, all printed to standard output.
Used in Delivery: publishing
Source scripts/dl/volume_manifest_check.py

Source code

"""
ID: X-DL-02
Title: Volume manifest check
Stage: DL
Purpose: Check a proposed chapter-to-volume manifest for an off-convention
    chapter id, a section id that does not reset at its own chapter's
    boundary, and a volume whose chapters are not in numeral-aware order.
Usage: python3 scripts/dl/volume_manifest_check.py --help
    In a shell: python3 scripts/dl/volume_manifest_check.py MANIFEST.json
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: A volume-manifest JSON file: a top-level "volumes" list. Each
    volume is a JSON object with a "volume" number and a "chapters"
    list. Each chapter is a JSON object with an "id" string (the
    expected "<part>.<chapter>" pattern, such as "3.9") and a
    "sections" list holding its own section ids.
Outputs: One line per finding, then a summary line, all printed to
    standard output.

A manifest whose basic shape does not match this input contract (the
top-level "volumes" list missing, a volume with no "chapters" list, or
a chapter entry that is not a JSON object) is a usage error (exit 2),
not a finding; the two finding kinds below only run once a chapter's
own shape can actually be read.

Errors (exit 1): a chapter whose "id" does not match the expected
"<part>.<chapter>" pattern. An off-convention id is flagged here, never
silently skipped; this is the one behavior change this script makes
from a real, confirmed bug in comparable chapter-filename parsing,
which moved on to the next file instead of reporting the one it could
not parse, quietly losing a chapter from a volume. The other finding:
a section id that does not start with its own chapter's id, evidence
that section numbering did not reset at the chapter boundary. A
chapter whose own id fails the pattern check is skipped for the
remaining checks on that one chapter, the same way a bad entry is
skipped elsewhere in this guide's scripts, so it does not also crowd
out the ordering warning below with a value it has no numeral-aware
sort key for. A chapter's own "sections" entry that is missing or is
not a JSON list is treated as holding no sections, so it contributes
no section-reset finding of its own.

Warnings (exit 0, never change the exit code): within one volume, the
chapters whose ids passed the pattern check are not in numeral-aware
order (so a chapter id such as "3.10" must sort after "3.9", not
before "3.2", the way a plain text sort would place it).
"""

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

sys.dont_write_bytecode = True
if sys.version_info < (3, 10):
    print(
        "volume_manifest_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
CHAPTER_ID_PATTERN = re.compile(r"^[0-9]+\.[0-9]+$")


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 numeral_key(chapter_id: str) -> tuple[int, int]:
    """Return the (part, chapter) integer sort key for a matched chapter id.

    Only call this on an id that already passed CHAPTER_ID_PATTERN, so the
    split and the two int() conversions always succeed.
    """
    part, chapter = chapter_id.split(".")
    return int(part), int(chapter)


def check_manifest(data: Any) -> tuple[list[str], list[str], int, int]:
    """Return (errors, warnings, volume_count, chapter_count)."""
    if not isinstance(data, dict):
        raise ValueError("the manifest is not a JSON object")
    volumes = data.get("volumes")
    if not isinstance(volumes, list) or not volumes:
        raise ValueError("'volumes' is missing, empty, or is not a JSON list")

    errors: list[str] = []
    warnings: list[str] = []
    chapter_count = 0

    for vpos, volume in enumerate(volumes, start=1):
        if not isinstance(volume, dict):
            raise ValueError(f"volume #{vpos} is not a JSON object")
        chapters = volume.get("chapters")
        if not isinstance(chapters, list) or not chapters:
            raise ValueError(f"volume #{vpos} has no 'chapters' list")
        number = volume.get("volume")
        if isinstance(number, (int, float)) and not isinstance(number, bool):
            vlabel = f"volume {number!r}"
        else:
            vlabel = f"volume #{vpos}"

        ordered_ids: list[str] = []
        for cpos, chapter in enumerate(chapters, start=1):
            chapter_count += 1
            if not isinstance(chapter, dict):
                raise ValueError(f"{vlabel} chapter #{cpos} is not a JSON object")
            label = f"{vlabel} chapter #{cpos}"
            chapter_id = chapter.get("id")
            if isinstance(chapter_id, str) and chapter_id:
                label = f"{vlabel} chapter {chapter_id!r}"
            if not (
                isinstance(chapter_id, str) and CHAPTER_ID_PATTERN.match(chapter_id)
            ):
                errors.append(
                    f"off-convention: {label} has id {chapter_id!r}, expected "
                    "the pattern '<part>.<chapter>' such as '3.9'"
                )
                continue
            ordered_ids.append(chapter_id)

            sections = chapter.get("sections")
            if not isinstance(sections, list):
                continue
            for section_id in sections:
                if not (
                    isinstance(section_id, str) and section_id.startswith(chapter_id)
                ):
                    errors.append(
                        f"no-reset: {label} has section {section_id!r}, which "
                        f"does not start with chapter id {chapter_id!r}"
                    )

        if ordered_ids and ordered_ids != sorted(ordered_ids, key=numeral_key):
            got = ", ".join(ordered_ids)
            expected = ", ".join(sorted(ordered_ids, key=numeral_key))
            warnings.append(
                f"order: {vlabel} chapters are not in numeral-aware order: "
                f"got {got}; expected {expected}"
            )

    return errors, warnings, len(volumes), chapter_count


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        prog="volume_manifest_check.py",
        description=(
            "Check a chapter-to-volume manifest's chapter ids, section-id "
            "resets and numeral-aware chapter order. Writes no files."
        ),
    )
    parser.add_argument(
        "manifest", metavar="MANIFEST", help="path to the volume-manifest 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:
        data = load_json(Path(args.manifest))
        errors, warnings, volume_count, chapter_count = check_manifest(data)
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"volume_manifest_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"volumes={volume_count} chapters={chapter_count} "
        f"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.